Capture unfiled work from a cold offline mobile launch #302

Merged
rockachopa merged 1 commits from timmy/301-cold-offline-capture into main 2026-08-08 12:40:25 +00:00
8 changed files with 244 additions and 11 deletions

View File

@ -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 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 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 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 comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to 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 another worker replays a confirmed result instead of posting duplicate content. Results

View File

@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; } .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 { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; } .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 { 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 { 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; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }

View File

@ -168,6 +168,11 @@
loadMilestones: item => issueController.loadMilestones(item), loadMilestones: item => issueController.loadMilestones(item),
}); });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
let backgroundIssueSync = null; let backgroundIssueSync = null;
if ('indexedDB' in window) { if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore(); const backgroundIssueStore = createIssueSyncStore();
@ -619,8 +624,21 @@
refreshMyWorkView(); 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() { function refreshMyWorkView() {
lastDrafts = draftInbox.list(); lastDrafts = listDrafts();
const partitioned = laterWork.partition(lastMyWork, { const partitioned = laterWork.partition(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more), pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
}); });
@ -687,6 +705,7 @@
const list = qs('#my-work-list'); const list = qs('#my-work-list');
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
const isUnfiled = item.kind === 'unfiled-issue';
const outboxActions = item.quarantined ? const outboxActions = item.quarantined ?
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' + '<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' : '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
@ -698,11 +717,12 @@
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
'<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' + '<button class="draft-send" data-draft-index="' + index + '" type="button">Send now</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' : '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Resume draft</button>' + '<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>'; '<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
const state = isOutbox ? const state = (isOutbox || isUnfiled) ?
'<span class="pill">' + (item.quarantined ? 'Identity protected' : '<span class="pill">' + (item.quarantined ? 'Identity protected' :
(item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' + (isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '</span>' +
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : ''; (item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
return '<article class="my-work-card draft-card">' + return '<article class="my-work-card draft-card">' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' + '<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
@ -715,7 +735,15 @@
button.addEventListener('click', () => { button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)]; const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return; 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); else if (item.route) workRoute.open(item.route);
}); });
}); });
@ -751,10 +779,11 @@
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (!window.confirm('Discard this unfinished draft?')) return; if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)]; 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?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id); else if (item) draftInbox.discard(item.id);
lastDrafts = draftInbox.list(); lastDrafts = listDrafts();
const count = qs('[data-work-count="draft"]'); const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length; if (count) count.textContent = lastDrafts.length;
renderDrafts(); renderDrafts();
@ -779,7 +808,7 @@
} }
function renderMyWork() { function renderMyWork() {
lastDrafts = draftInbox.list(); lastDrafts = listDrafts();
const draftCount = qs('[data-work-count="draft"]'); const draftCount = qs('[data-work-count="draft"]');
if (draftCount) draftCount.textContent = lastDrafts.length; if (draftCount) draftCount.textContent = lastDrafts.length;
if (selectedWorkFilter === 'draft') { if (selectedWorkFilter === 'draft') {
@ -2102,6 +2131,24 @@
} }
}); });
qs('#new-issue').addEventListener('click', openCreateIssueSheet); 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', () => { qs('#use-shared-content').addEventListener('click', () => {
issueCapture.acceptSharedContent(); issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true; qs('#shared-content-conflict').hidden = true;

View File

@ -335,6 +335,7 @@
</fieldset> </fieldset>
<div class="small">The issue will be assigned to you.</div> <div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions"> <div class="create-issue-actions">
<button id="save-unfiled-issue" type="button">Save for filing</button>
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button> <button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
<div id="create-issue-status" class="small" aria-live="assertive"></div> <div id="create-issue-status" class="small" aria-live="assertive"></div>
</div> </div>
@ -506,6 +507,7 @@
<script src="static/search-preview.js"></script> <script src="static/search-preview.js"></script>
<script src="static/widgets.js"></script> <script src="static/widgets.js"></script>
<script src="static/drafts.js"></script> <script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/outbox-coordinator.js"></script> <script src="static/outbox-coordinator.js"></script>
<script src="static/background-issue-sync.js"></script> <script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script> <script src="static/issue-outbox.js"></script>

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,
@ -15,6 +15,7 @@ const SHELL = [
BASE + 'static/search-preview.js', BASE + 'static/search-preview.js',
BASE + 'static/widgets.js', BASE + 'static/widgets.js',
BASE + 'static/drafts.js', BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/outbox-coordinator.js', BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js', BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js', BASE + 'static/authored-outbox.js',

View File

@ -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;

View File

@ -95,7 +95,7 @@ async function dispatchNotificationClick(route) {{
def test_strict_browser_assets_ship_in_a_new_shell_cache(): def test_strict_browser_assets_ship_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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/search-preview.js",
"/dashboard/static/widgets.js", "/dashboard/static/widgets.js",
"/dashboard/static/drafts.js", "/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/outbox-coordinator.js", "/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js", "/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js", "/dashboard/static/authored-outbox.js",

View File

@ -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 '<script src="static/unfiled-captures.js"></script>' 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