diff --git a/README.md b/README.md
index 31a8780..6227e40 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,12 @@ issue-comment API; mobile issue capture requires issue
creation and assignment permission. The New issue sheet can optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
-lane immediately. Issue capture and authored mobile actions (issue
+lane immediately. On a cold offline launch, **Save for filing** stores up to 20
+account-bound title/description captures without selecting a repository or entering the
+mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
+the same Gitea login, **Choose repository** restores the capture to the normal planning
+and durable delivery flow. A different or unconfirmed account can only copy or discard
+the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index a6ac3ca..526cda5 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
+.create-issue-actions button { min-height:44px; max-width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index a0fd30c..98bd378 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -168,6 +168,11 @@
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
+ const unfiledCaptures = createUnfiledCaptures({
+ storage: localStorage,
+ getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
+ getCurrentLogin: () => activeFlushLogin,
+ });
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
@@ -619,8 +624,21 @@
refreshMyWorkView();
}
+ function listDrafts() {
+ const unfiled = unfiledCaptures.list().map(item => ({
+ id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
+ title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
+ updated_at:item.savedAt, quarantined:item.quarantined,
+ ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
+ (activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
+ }));
+ return draftInbox.list().concat(unfiled).sort((left, right) =>
+ Number(right.updated_at || 0) - Number(left.updated_at || 0)
+ );
+ }
+
function refreshMyWorkView() {
- lastDrafts = draftInbox.list();
+ lastDrafts = listDrafts();
const partitioned = laterWork.partition(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
});
@@ -687,6 +705,7 @@
const list = qs('#my-work-list');
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
+ const isUnfiled = item.kind === 'unfiled-issue';
const outboxActions = item.quarantined ?
'' +
'' :
@@ -698,11 +717,12 @@
'' +
'' +
'' :
- '' +
+ '' +
'';
- const state = isOutbox ?
+ const state = (isOutbox || isUnfiled) ?
'' + (item.quarantined ? 'Identity protected' :
- (item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' +
+ (isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' +
(item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : '';
return '' +
'' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' +
@@ -715,7 +735,15 @@
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
- if (item.kind === 'new-issue') openCreateIssueSheet();
+ if (item.kind === 'unfiled-issue') {
+ try {
+ const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
+ issueCapture.saveDraft(resumed);
+ refreshMyWorkView();
+ openCreateIssueSheet();
+ qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
+ } catch (error) { qs('#my-work-action-status').textContent = error.message; }
+ } else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
});
});
@@ -751,10 +779,11 @@
button.addEventListener('click', () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
- if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
+ if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
+ else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
- lastDrafts = draftInbox.list();
+ lastDrafts = listDrafts();
const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length;
renderDrafts();
@@ -779,7 +808,7 @@
}
function renderMyWork() {
- lastDrafts = draftInbox.list();
+ lastDrafts = listDrafts();
const draftCount = qs('[data-work-count="draft"]');
if (draftCount) draftCount.textContent = lastDrafts.length;
if (selectedWorkFilter === 'draft') {
@@ -2102,6 +2131,24 @@
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
+ qs('#save-unfiled-issue').addEventListener('click', () => {
+ const captureDraft = {
+ title: qs('#create-issue-title').value.trim(),
+ body: qs('#create-issue-body').value.trim(),
+ };
+ try {
+ unfiledCaptures.save(captureDraft);
+ issueCapture.clearDraft();
+ qs('#create-issue-title').value = '';
+ qs('#create-issue-body').value = '';
+ closeCreateIssueSheet();
+ refreshMyWorkView();
+ qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
+ } catch (error) {
+ qs('#create-issue-status').textContent = error.message;
+ qs('#create-issue-title').focus();
+ }
+ });
qs('#use-shared-content').addEventListener('click', () => {
issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
diff --git a/frontend/index.html b/frontend/index.html
index cfb81fa..0203188 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -335,6 +335,7 @@
The issue will be assigned to you.
+
@@ -506,6 +507,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 7bd8d25..b9bd972 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v28';
+const CACHE = 'stackchain-dashboard-shell-v29';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@@ -15,6 +15,7 @@ const SHELL = [
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
+ BASE + 'static/unfiled-captures.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js
new file mode 100644
index 0000000..9323b11
--- /dev/null
+++ b/frontend/unfiled-captures.js
@@ -0,0 +1,65 @@
+function createUnfiledCaptures({
+ storage,
+ getCaptureLogin = () => '',
+ getCurrentLogin = () => '',
+ createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
+ now = () => Date.now(),
+ maxItems = 20,
+}) {
+ const storageKey = 'stackchain.unfiled-issues.v1';
+
+ function read() {
+ try {
+ const record = JSON.parse(storage?.getItem(storageKey) || 'null');
+ if (record?.version !== 1 || !Array.isArray(record.items)) return [];
+ return record.items.filter(item =>
+ item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
+ typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
+ );
+ } catch (_error) { return []; }
+ }
+
+ function write(items) {
+ storage?.setItem(storageKey, JSON.stringify({version:1, items}));
+ }
+
+ function list() {
+ const currentLogin = String(getCurrentLogin() || '').trim();
+ return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
+ .map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
+ }
+
+ function save(note) {
+ const title = String(note?.title || '').trim().slice(0, 255);
+ const body = String(note?.body || '').trim().slice(0, 10000);
+ if (!title) throw new Error('Add a title before saving.');
+ const ownerLogin = String(getCaptureLogin() || '').trim();
+ if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
+ const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
+ const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
+ write(items);
+ return item;
+ }
+
+ function discard(id) {
+ const items = read();
+ const remaining = items.filter(item => item.id !== id);
+ if (remaining.length === items.length) return false;
+ write(remaining);
+ return true;
+ }
+
+ function resume(id, confirmedLogin) {
+ const item = read().find(candidate => candidate.id === id);
+ if (!item) throw new Error('This capture is no longer available.');
+ if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
+ throw new Error('Reconnect with the account that saved this capture.');
+ }
+ discard(id);
+ return {repository:'', title:item.title, body:item.body, labelIds:[]};
+ }
+
+ return {list, save, discard, resume};
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 940a861..1bee7c1 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -95,7 +95,7 @@ async function dispatchNotificationClick(route) {{
def test_strict_browser_assets_ship_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v28" in source
+ assert "stackchain-dashboard-shell-v29" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -217,6 +217,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
+ "/dashboard/static/unfiled-captures.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py
new file mode 100644
index 0000000..35ebbb4
--- /dev/null
+++ b/tests/test_unfiled_captures.py
@@ -0,0 +1,111 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
+
+
+def run_node(script: str):
+ result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+ return json.loads(result.stdout)
+
+
+def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
+ script = f"""
+const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.get(key) || null,
+ setItem:(key,value) => values.set(key,value),
+ removeItem:key => values.delete(key),
+}};
+let sequence = 0;
+const captures = createUnfiledCaptures({{
+ storage,
+ getCaptureLogin:()=>'timmy',
+ getCurrentLogin:()=>'',
+ createId:()=>String(++sequence),
+ now:()=>1000 + sequence,
+}});
+for (let index = 1; index <= 22; index += 1) {{
+ captures.save({{title:'Note ' + index, body:'Context ' + index}});
+}}
+const offline = captures.list();
+const restored = createUnfiledCaptures({{
+ storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
+}}).list();
+process.stdout.write(JSON.stringify({{offline, restored}}));
+"""
+ output = run_node(script)
+
+ assert len(output["offline"]) == 20
+ assert output["offline"][0]["title"] == "Note 22"
+ assert output["offline"][-1]["title"] == "Note 3"
+ assert all(item["quarantined"] for item in output["offline"])
+ assert all(not item["quarantined"] for item in output["restored"])
+ assert output["restored"][0]["ownerLogin"] == "timmy"
+
+
+def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
+ script = f"""
+const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
+const values = new Map();
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+let id = 0;
+const captures = createUnfiledCaptures({{
+ storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id
+}});
+const first = captures.save({{title:'First',body:'One'}});
+const second = captures.save({{title:'Second',body:'Two'}});
+let mismatch = '';
+try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
+const resumed = captures.resume(first.id, 'timmy');
+process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
+"""
+ output = run_node(script)
+
+ assert output["mismatch"] == "Reconnect with the account that saved this capture."
+ assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []}
+ assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
+
+
+def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
+ script = f"""
+const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
+const values = new Map();
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}});
+const errors = [];
+for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{
+ try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }}
+}}
+process.stdout.write(JSON.stringify({{errors,size:values.size}}));
+"""
+ output = run_node(script)
+
+ assert output == {
+ "errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
+ "size": 0,
+ }
+
+
+@pytest.mark.anyio
+async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow():
+ html = await dashboard()
+
+ assert '' in html
+ assert 'id="save-unfiled-issue"' in html
+ assert 'Save for filing' in html
+ assert "createUnfiledCaptures({" in html
+ assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
+ assert "unfiledCaptures.save(captureDraft)" in html
+ assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
+ assert "issueCapture.saveDraft(resumed)" in html
+ assert "item.kind === 'unfiled-issue'" in html
+ assert '.create-issue-actions button { min-height:44px;' in html
+ assert '@media(max-width:320px)' in html