Defer
@@ -930,6 +931,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 4ae21ad..b7f2842 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -48,6 +48,7 @@ const SHELL = [
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
+ BASE + 'static/update-follow-up.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/later-and-start.js',
diff --git a/frontend/update-follow-up.js b/frontend/update-follow-up.js
new file mode 100644
index 0000000..639a0a9
--- /dev/null
+++ b/frontend/update-follow-up.js
@@ -0,0 +1,32 @@
+function createUpdateFollowUp() {
+ const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
+ const safeRepository = value => {
+ const candidate = String(value || '');
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
+ candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
+ };
+ const safeUrl = value => {
+ try {
+ const parsed = new URL(String(value || ''));
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
+ } catch (_error) { return ''; }
+ };
+
+ function draft(detail = {}) {
+ const source = safeUrl(detail.url || detail.latest_comment?.url);
+ const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
+ const title = clean(detail.title, 228);
+ const sections = [];
+ if (source) sections.push('Source: ' + source);
+ if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
+ return {
+ repository: safeRepository(detail.repository),
+ title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
+ body: sections.join('\n\n').slice(0, 9500),
+ };
+ }
+
+ return { draft };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 7ce0b15..a895bd2 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -711,6 +711,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
"/dashboard/static/update-ownership.js",
+ "/dashboard/static/update-follow-up.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
"/dashboard/static/later-and-start.js",
diff --git a/tests/test_update_follow_up.py b/tests/test_update_follow_up.py
new file mode 100644
index 0000000..7bfbfd9
--- /dev/null
+++ b/tests/test_update_follow_up.py
@@ -0,0 +1,104 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+ROOT = Path(__file__).parents[1]
+UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js"
+CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js"
+
+
+def run_node(script: str) -> dict:
+ result = subprocess.run(
+ ["node", "-e", script], capture_output=True, text=True, timeout=10
+ )
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def test_follow_up_derives_repository_editable_title_and_canonical_context():
+ script = f"""
+const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
+const controller = createFollowUp();
+process.stdout.write(JSON.stringify(controller.draft({{
+ repository: 'stackchain/stackchain-dashboard',
+ title: 'Fix mobile queue',
+ subject_type: 'Pull',
+ url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42',
+ latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }}
+}})));
+"""
+ assert run_node(script) == {
+ "repository": "stackchain/stackchain-dashboard",
+ "title": "Follow up: Fix mobile queue",
+ "body": (
+ "Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n"
+ "Latest context:\n> Please preserve the unread state. Ship this on mobile."
+ ),
+ }
+
+
+def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url():
+ script = f"""
+const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
+const controller = createFollowUp();
+process.stdout.write(JSON.stringify(controller.draft({{
+ repository: '../admin', title: '',
+ url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}}
+}})));
+"""
+ output = run_node(script)
+ assert output["repository"] == ""
+ assert output["title"] == "Follow up: "
+ assert "javascript:" not in output["body"]
+ assert len(output["body"]) <= 9500
+
+
+def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
+ script = f"""
+const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+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 capture = createCapture({{fetchJson:async()=>[], storage}});
+capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}});
+const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}});
+const before = capture.loadDraft();
+const accepted = capture.acceptFollowUp();
+process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}}));
+"""
+ assert run_node(script) == {
+ "state": {"status": "conflict"},
+ "before": {
+ "repository": "o/existing",
+ "title": "Existing draft",
+ "body": "Keep me",
+ "labelIds": [],
+ },
+ "accepted": {
+ "repository": "o/new",
+ "title": "Follow up",
+ "body": "Source: https://example.test/1",
+ "labelIds": [],
+ },
+ "pending": None,
+ }
+
+
+@pytest.mark.anyio
+async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
+ html = await dashboard()
+
+ assert '' 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