From 2553e7121b366700793d35b1ae5adf624f4d83a1 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 17:10:16 +0000 Subject: [PATCH] feat: create follow-up and continue updates (Closes #665) --- frontend/dashboard.js | 22 ++++++-- frontend/index.html | 1 + frontend/my-work.js | 3 +- frontend/update-follow-up.js | 59 ++++++++++++++++++++- src/frontend_bundle.py | 2 +- tests/test_update_follow_up.py | 96 +++++++++++++++++++++++++++++++++- 6 files changed, 175 insertions(+), 8 deletions(-) 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