diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index a97f3c5..88502e4 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -409,6 +409,7 @@
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
+ let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
@@ -498,6 +499,7 @@
}, () => {
if (!issueCapture) {
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
+ updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
@@ -3348,6 +3350,7 @@
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
+ qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
updateIssueCreateActions();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
@@ -4178,11 +4181,11 @@
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
- const updateFollowUp = createUpdateFollowUp();
qs('#create-update-follow-up').addEventListener('click', async () => {
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
+ updateFollowUp.stageSource(source.item);
followUpSourceUpdate = source;
closeUpdateSheet(false, false);
await openCreateIssueSheet(false);
@@ -4193,6 +4196,7 @@
} else {
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
}
+ qs('#create-follow-up-next').hidden = false;
});
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
@@ -4251,6 +4255,7 @@
qs('#resume-issue-draft').addEventListener('click', () => {
issueCapture.discardSharedContent();
issueCapture.discardFollowUp();
+ updateFollowUp.discardSource();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
@@ -4334,6 +4339,7 @@
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
+ const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
@@ -4353,8 +4359,10 @@
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
+ const followUpButton = qs('#create-follow-up-next');
button.disabled = true;
startButton.disabled = true;
+ followUpButton.disabled = true;
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
@@ -4364,8 +4372,13 @@
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
- const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
- await issueOutbox.enqueueDurably(durableDraft);
+ const admission = followUpNextRequested ? (await updateFollowUp.complete({
+ admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
+ issueOutbox.enqueueDurably(durableDraft),
+ queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
+ advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
+ })).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
+ await issueOutbox.enqueueDurably(durableDraft));
const queued = admission.item;
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
@@ -4385,6 +4398,8 @@
return;
}
editingOutboxId = null;
+ followUpSourceUpdate = null;
+ if (!followUpNextRequested) updateFollowUp.discardSource();
issueCapture.clearDraft();
createIssueAttachmentController.clear();
suppressCreateDraftOnHistoryClose = true;
@@ -4399,6 +4414,7 @@
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
+ followUpButton.disabled = false;
qs('#create-issue-title').focus();
}
});
diff --git a/frontend/index.html b/frontend/index.html
index 67bcc2d..ce42380 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -609,6 +609,7 @@
The issue will be assigned to you.
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index 1c82bca..f2cf02b 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -404,7 +404,8 @@ function createNotificationReader({
return true;
},
acceptReadAndNext(items, item = selected) {
- if (!item || selected !== item) return false;
+ if (!item || (selected && selected !== item)) return false;
+ selected = item;
return advanceAfterRead(items, item);
},
async loadOlder() {
diff --git a/frontend/update-follow-up.js b/frontend/update-follow-up.js
index 639a0a9..5cb7018 100644
--- a/frontend/update-follow-up.js
+++ b/frontend/update-follow-up.js
@@ -1,4 +1,5 @@
-function createUpdateFollowUp() {
+function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
+ const sourceKey = 'stackchain.update-follow-up-source.v1';
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
const safeRepository = value => {
const candidate = String(value || '');
@@ -26,7 +27,61 @@ function createUpdateFollowUp() {
};
}
- return { draft };
+ function readSource() {
+ const login = String(getLogin() || '').trim();
+ if (!login) return null;
+ try {
+ const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
+ return record?.version === 1 && record.ownerLogin === login &&
+ Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
+ } catch (_error) { return null; }
+ }
+
+ function writeSource(record) {
+ storage?.setItem(sourceKey, JSON.stringify(record));
+ }
+
+ function stageSource(item = {}) {
+ const ownerLogin = String(getLogin() || '').trim();
+ const notificationId = Number(item.notification_id);
+ if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
+ if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
+ const source = {
+ notification_id: notificationId,
+ repository: safeRepository(item.repository),
+ number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
+ title: clean(item.title, 240), kind: 'update', has_update: true,
+ };
+ writeSource({ version:1, ownerLogin, source, admission:null });
+ return { ...source };
+ }
+
+ function source() {
+ const record = readSource();
+ return record ? { ...record.source } : null;
+ }
+
+ function discardSource() {
+ if (!readSource()) return false;
+ storage?.removeItem(sourceKey);
+ return true;
+ }
+
+ async function complete({ admit, queueRead, advance }) {
+ const record = readSource();
+ if (!record) throw new Error('Reopen the source update before continuing.');
+ let admission = record.admission;
+ if (!admission) {
+ admission = await admit();
+ writeSource({ ...record, admission });
+ }
+ await queueRead(record.source.notification_id);
+ const advanced = await advance({ ...record.source });
+ storage?.removeItem(sourceKey);
+ return { admission, advanced:Boolean(advanced) };
+ }
+
+ return { draft, stageSource, source, discardSource, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 79702dd..02f7f2d 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -22,7 +22,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
- "issue-capture": ("static/create-issue-sheet.js",),
+ "issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
diff --git a/tests/test_update_follow_up.py b/tests/test_update_follow_up.py
index 7bfbfd9..eeef978 100644
--- a/tests/test_update_follow_up.py
+++ b/tests/test_update_follow_up.py
@@ -58,6 +58,87 @@ process.stdout.write(JSON.stringify(controller.draft({{
assert len(output["body"]) <= 9500
+def test_follow_up_continuation_is_account_bound_and_survives_reload():
+ script = f"""
+const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
+}};
+let login = 'timmy';
+const first = createFollowUp({{storage, getLogin:()=>login}});
+const staged = first.stageSource({{
+ notification_id: 42, repository:'stackchain/stackchain-dashboard', number:665,
+ title:'Unread follow-up source', kind:'update', has_update:true
+}});
+const restored = createFollowUp({{storage, getLogin:()=>login}}).source();
+login = 'alexander';
+const isolated = createFollowUp({{storage, getLogin:()=>login}}).source();
+process.stdout.write(JSON.stringify({{staged, restored, isolated}}));
+"""
+ output = run_node(script)
+ assert output["staged"]["notification_id"] == 42
+ assert output["restored"] == output["staged"]
+ assert output["isolated"] is None
+
+
+def test_follow_up_completion_admits_issue_before_durable_read_and_advances_once():
+ script = f"""
+const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
+}};
+const order = [];
+const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
+controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
+(async () => {{
+ const result = await controller.complete({{
+ admit:async()=>{{ order.push('issue'); return {{item:{{id:'issue:1'}}}}; }},
+ queueRead:async id=>{{ order.push('read:' + id); return {{item:{{id:'read:42'}}}}; }},
+ advance:async source=>{{ order.push('next:' + source.notification_id); return true; }},
+ }});
+ process.stdout.write(JSON.stringify({{order, result, pending:controller.source()}}));
+}})();
+"""
+ output = run_node(script)
+ assert output == {
+ "order": ["issue", "read:42", "next:42"],
+ "result": {"admission": {"item": {"id": "issue:1"}}, "advanced": True},
+ "pending": None,
+ }
+
+
+def test_follow_up_completion_preserves_source_when_issue_admission_fails():
+ script = f"""
+const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
+}};
+const order = [];
+const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
+controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
+(async () => {{
+ try {{
+ await controller.complete({{
+ admit:async()=>{{ order.push('issue'); throw new Error('disk full'); }},
+ queueRead:async()=>order.push('read'), advance:async()=>order.push('next'),
+ }});
+ }} catch (error) {{
+ process.stdout.write(JSON.stringify({{order, error:error.message, pending:controller.source()}}));
+ }}
+}})();
+"""
+ output = run_node(script)
+ assert output["order"] == ["issue"]
+ assert output["error"] == "disk full"
+ assert output["pending"]["notification_id"] == 42
+
+
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
script = f"""
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
@@ -95,10 +176,23 @@ process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.p
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
html = await dashboard()
- assert '' in html
+ assert 'name="stackchain-feature-issue-capture"' in html
assert 'id="create-update-follow-up"' in html
assert '>Create follow-up' in html
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
assert "qs('#create-update-follow-up').addEventListener('click'" in html
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
+
+
+@pytest.mark.anyio
+async def test_follow_up_capture_offers_durable_create_and_next_flow():
+ html = await dashboard()
+
+ assert 'id="create-follow-up-next"' in html
+ assert '>Create follow-up & next' in html
+ assert "updateFollowUp.stageSource(source.item)" in html
+ assert "event.submitter?.id === 'create-follow-up-next'" in html
+ assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
+ assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
+ assert ".create-issue-actions button { min-height:44px;" in html