diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js
index 7fa1bf1..e1f55bd 100644
--- a/frontend/create-issue-sheet.js
+++ b/frontend/create-issue-sheet.js
@@ -166,7 +166,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
- saveDraft, loadDraft, loadLabels, loadMilestones, submit,
+ saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
diff --git a/frontend/drafts.js b/frontend/drafts.js
index 444817d..2d974b1 100644
--- a/frontend/drafts.js
+++ b/frontend/drafts.js
@@ -98,6 +98,26 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
return null;
}
+ function parseOutbox(raw) {
+ try {
+ const record = JSON.parse(raw);
+ if (record?.version !== 1 || !Array.isArray(record.items)) return [];
+ return record.items.filter(item =>
+ item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
+ ).map(item => ({
+ id: 'stackchain.issue-outbox.v1:' + item.id,
+ outbox_id: item.id,
+ kind: 'issue-outbox',
+ status: item.status === 'attention' ? 'attention' : 'queued',
+ label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
+ repository: item.repository,
+ title: textPreview(item.title) || 'Untitled queued issue',
+ preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
+ updated_at: Number(item.queuedAt || 0),
+ }));
+ } catch (_error) { return []; }
+ }
+
function list() {
const index = readIndex();
const seen = new Set();
@@ -107,6 +127,11 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
let raw;
try { raw = storage.getItem(key); }
catch (_error) { return; }
+ if (key === 'stackchain.issue-outbox.v1') {
+ parseOutbox(raw).forEach(item => drafts.push(item));
+ seen.add(key);
+ return;
+ }
const parsed = parse(key, raw);
if (!parsed) return;
seen.add(key);
diff --git a/frontend/index.html b/frontend/index.html
index e37b613..404a1e6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -75,7 +75,7 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
-.draft-actions { display:grid; grid-template-columns:1fr 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%; }
.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; }
@@ -718,6 +718,7 @@ textarea { resize: vertical; min-height: 120px; }
+
@@ -798,11 +799,16 @@ textarea { resize: vertical; min-height: 120px; }
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
+ let editingOutboxId = null;
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(payload.error || 'Review request failed.');
+ if (!response.ok) {
+ const error = new Error(payload.error || payload.detail || 'Review request failed.');
+ error.status = response.status;
+ throw error;
+ }
return payload;
}
@@ -823,6 +829,7 @@ textarea { resize: vertical; min-height: 120px; }
loadMilestones: item => issueController.loadMilestones(item),
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
+ const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
@@ -1243,15 +1250,22 @@ textarea { resize: vertical; min-height: 120px; }
function renderDrafts() {
const list = qs('#my-work-list');
- list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) =>
- '' +
- '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' +
- '' + escapeHtml(item.title) + '' +
- '' + escapeHtml(item.preview || 'Unfinished draft') + '' +
- 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' +
- '' +
- '
'
- ).join('') : '
No unfinished drafts.
';
+ list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
+ const outboxActions = item.kind === 'issue-outbox' ?
+ '' +
+ '' +
+ '' :
+ '' +
+ '';
+ const state = item.kind === 'issue-outbox' ?
+ '' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '' : '';
+ return '' +
+ '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' +
+ '' + escapeHtml(item.title) + '' +
+ '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state +
+ 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' +
+ '' + outboxActions + '
';
+ }).join('') : 'No unfinished drafts.
';
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
@@ -1260,10 +1274,31 @@ textarea { resize: vertical; min-height: 120px; }
else if (item.route) workRoute.open(item.route);
});
});
+ list.querySelectorAll('.draft-edit').forEach(button => {
+ button.addEventListener('click', () => {
+ const item = lastDrafts[Number(button.dataset.draftIndex)];
+ const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
+ if (!queued) return;
+ editingOutboxId = queued.id;
+ issueCapture.saveDraft(queued);
+ openCreateIssueSheet();
+ qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
+ });
+ });
+ list.querySelectorAll('.draft-send').forEach(button => {
+ button.addEventListener('click', async () => {
+ const item = lastDrafts[Number(button.dataset.draftIndex)];
+ if (!item?.outbox_id) return;
+ button.disabled = true;
+ applyOutboxResult(await issueOutbox.retry(item.outbox_id));
+ });
+ });
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', () => {
if (!window.confirm('Discard this unfinished draft?')) return;
- draftInbox.discard(button.dataset.draftId);
+ const item = lastDrafts[Number(button.dataset.draftIndex)];
+ if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
+ else if (item) draftInbox.discard(item.id);
lastDrafts = draftInbox.list();
const count = qs('[data-work-count="draft"]');
if (count) count.textContent = lastDrafts.length;
@@ -1909,6 +1944,32 @@ textarea { resize: vertical; min-height: 120px; }
qs('#new-issue').focus();
}
+ function applyOutboxResult(result, openCreated = false) {
+ (result.confirmed || []).forEach(confirmed => {
+ if (lastContextSnapshot) lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
+ });
+ if (lastContextSnapshot) lastMyWork = buildMyWork(lastContextSnapshot);
+ refreshMyWorkView();
+ if (result.confirmed?.length) {
+ const keys = result.confirmed.map(issue => issue.repository + '#' + issue.number).join(', ');
+ qs('#my-work-action-status').textContent = keys + ' created and assigned to you.';
+ const confirmed = result.confirmed[result.confirmed.length - 1];
+ const created = lastMyWork.find(item =>
+ item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
+ );
+ if (openCreated && created) openRoutedWork(created, qs('#new-issue'));
+ } else if ((result.remaining || []).some(item => item.status === 'attention')) {
+ qs('#my-work-action-status').textContent = 'Needs attention · edit the queued issue before sending again.';
+ } else {
+ qs('#my-work-action-status').textContent = 'Queued for sync when the connection returns.';
+ }
+ }
+
+ async function flushIssueOutbox() {
+ if (!navigator.onLine || !issueOutbox.list().length) return;
+ applyOutboxResult(await issueOutbox.flush());
+ }
+
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
@@ -2491,7 +2552,10 @@ textarea { resize: vertical; min-height: 120px; }
qs('#create-issue-title').focus();
});
qs('#cancel-new-issue').addEventListener('click', () => {
- saveIssueCaptureDraft();
+ if (editingOutboxId) {
+ issueCapture.clearDraft();
+ editingOutboxId = null;
+ } else saveIssueCaptureDraft();
closeCreateIssueSheet();
});
['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector =>
@@ -2521,18 +2585,16 @@ textarea { resize: vertical; min-height: 120px; }
}
const button = qs('#submit-new-issue');
button.disabled = true;
- qs('#create-issue-status').textContent = 'Creating issue…';
+ qs('#create-issue-status').textContent = navigator.onLine ? 'Sending issue…' : 'Saving issue to outbox…';
try {
- const confirmed = await issueCapture.submit(captureDraft);
- lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
- lastMyWork = buildMyWork(lastContextSnapshot);
- const created = lastMyWork.find(item =>
- item.kind === 'issue' && item.repository === confirmed.repository && item.number === confirmed.number
- );
+ const queued = editingOutboxId ? issueOutbox.update(editingOutboxId, captureDraft) :
+ issueOutbox.enqueue(captureDraft);
+ editingOutboxId = null;
+ issueCapture.clearDraft();
closeCreateIssueSheet();
refreshMyWorkView();
- qs('#my-work-action-status').textContent = created.key + ' created and assigned to you.';
- openRoutedWork(created, qs('#new-issue'));
+ qs('#my-work-action-status').textContent = 'Queued for sync.';
+ if (navigator.onLine) applyOutboxResult(await issueOutbox.retry(queued.id), true);
} catch (error) {
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
@@ -3089,6 +3151,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!navigator.onLine) showOfflineStatus();
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
+ window.addEventListener('online', flushIssueOutbox);
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
@@ -3173,6 +3236,7 @@ textarea { resize: vertical; min-height: 120px; }
);
}
contextPoller.start();
+ flushIssueOutbox();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
});
diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js
new file mode 100644
index 0000000..871c8c4
--- /dev/null
+++ b/frontend/issue-outbox.js
@@ -0,0 +1,123 @@
+function createIssueOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 20 }) {
+ const storageKey = 'stackchain.issue-outbox.v1';
+ const operationId = createOperationId || (() =>
+ globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
+ );
+ const pending = new Map();
+
+ 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 === 'object');
+ } catch (_error) { return []; }
+ }
+
+ function write(items) {
+ storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
+ }
+
+ function enqueue(draft) {
+ const items = read();
+ if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
+ const item = {
+ id: String(operationId()).slice(0, 128),
+ operationId: '',
+ repository: String(draft?.repository || ''),
+ title: String(draft?.title || ''),
+ body: String(draft?.body || ''),
+ labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
+ status: 'queued',
+ queuedAt: Number(now()),
+ };
+ item.operationId = item.id;
+ if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
+ item.milestoneId = Number(draft.milestoneId);
+ }
+ if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
+ items.push(item);
+ write(items);
+ return item;
+ }
+
+ function update(id, draft) {
+ let updated = null;
+ write(read().map(item => {
+ if (item.id !== id) return item;
+ updated = {
+ ...item,
+ repository: String(draft?.repository || ''), title: String(draft?.title || ''),
+ body: String(draft?.body || ''),
+ labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
+ status: 'queued',
+ };
+ delete updated.error;
+ return updated;
+ }));
+ return updated;
+ }
+
+ function discard(id) {
+ const items = read();
+ if (!items.some(item => item.id === id)) return false;
+ write(items.filter(item => item.id !== id));
+ return true;
+ }
+
+ async function sendItem(item) {
+ if (pending.has(item.id)) return pending.get(item.id);
+ const repository = item.repository.split('/').map(encodeURIComponent).join('/');
+ const request = (async () => {
+ try {
+ const issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json', 'Content-Type': 'application/json',
+ 'Idempotency-Key': item.operationId,
+ },
+ body: JSON.stringify({
+ title: item.title, body: item.body, label_ids: item.labelIds,
+ ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
+ ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
+ }),
+ });
+ discard(item.id);
+ return { issue };
+ } catch (error) {
+ const status = Number(error?.status || 0);
+ if (status >= 400 && status < 500) {
+ write(read().map(candidate => candidate.id === item.id ? {
+ ...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
+ } : candidate));
+ }
+ return { error, transient: !(status >= 400 && status < 500) };
+ }
+ })();
+ pending.set(item.id, request);
+ try { return await request; }
+ finally { if (pending.get(item.id) === request) pending.delete(item.id); }
+ }
+
+ async function flush() {
+ const confirmed = [];
+ for (const item of read()) {
+ if (item.status === 'attention') continue;
+ const result = await sendItem(item);
+ if (result.issue) confirmed.push(result.issue);
+ if (result.transient) break;
+ }
+ return { confirmed, remaining: read() };
+ }
+
+ async function retry(id) {
+ const item = read().find(candidate => candidate.id === id);
+ if (!item) return { confirmed: [], remaining: read() };
+ update(id, item);
+ const result = await sendItem({ ...item, status: 'queued' });
+ return { confirmed: result.issue ? [result.issue] : [], remaining: read() };
+ }
+
+ return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index e3db038..aa21115 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE = 'stackchain-dashboard-shell-v4';
+const CACHE = 'stackchain-dashboard-shell-v5';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
@@ -11,6 +11,7 @@ const SHELL = [
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
+ BASE + 'static/issue-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/pick-work.js',
diff --git a/tests/test_drafts.py b/tests/test_drafts.py
index 97fd698..7a48ac8 100644
--- a/tests/test_drafts.py
+++ b/tests/test_drafts.py
@@ -102,6 +102,25 @@ process.stdout.write(JSON.stringify(drafts));
assert output[0]["route"] == {"kind": "issue", "repository": "stackchain/api", "number": 18}
+def test_draft_inbox_expands_issue_outbox_items_with_actionable_states():
+ script = f"""
+const createDraftInbox = require({json.dumps(str(DRAFTS))});
+const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:1,items:[
+ {{id:'queued-1',repository:'stackchain/api',title:'Offline capture',body:'Context',status:'queued',queuedAt:100}},
+ {{id:'attention-2',repository:'stackchain/web',title:'Fix labels',body:'Details',status:'attention',error:'Unknown label',queuedAt:200}},
+]}})]]);
+const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+const drafts = createDraftInbox({{storage,now:()=>300}}).list();
+process.stdout.write(JSON.stringify(drafts));
+"""
+ output = run_node(script)
+
+ assert [item["outbox_id"] for item in output] == ["attention-2", "queued-1"]
+ assert [item["status"] for item in output] == ["attention", "queued"]
+ assert output[0]["label"] == "Needs attention"
+ assert output[1]["label"] == "Queued issue"
+
+
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py
new file mode 100644
index 0000000..21db5d4
--- /dev/null
+++ b/tests/test_issue_outbox.py
@@ -0,0 +1,150 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from src.views import dashboard
+
+
+OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.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_issue_outbox_queues_multiple_planned_issues_with_stable_operation_ids():
+ script = f"""
+const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+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 sequence = 0;
+const outbox = createIssueOutbox({{storage, createOperationId:() => 'op-' + (++sequence), now:() => 1000 + sequence}});
+outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One',labelIds:[3]}});
+outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two',milestoneId:4,dueDate:'2026-08-09'}});
+const reloaded = createIssueOutbox({{storage}}).list();
+process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.get('stackchain.issue-outbox.v1'))}}));
+"""
+ output = run_node(script)
+
+ assert [(item["repository"], item["title"]) for item in output["items"]] == [
+ ("stackchain/api", "First"),
+ ("stackchain/web", "Second"),
+ ]
+ assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
+ assert all(item["status"] == "queued" for item in output["items"])
+ assert output["stored"]["version"] == 1
+
+
+def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
+ script = f"""
+const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+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 sequence = 0;
+const calls = [];
+const outbox = createIssueOutbox({{
+ storage,
+ createOperationId:() => 'stable-' + (++sequence),
+ fetchJson:async (url, options) => {{
+ calls.push({{url, key:options.headers['Idempotency-Key']}});
+ if (calls.length === 2) {{ const error = new Error('upstream unavailable'); error.status = 503; throw error; }}
+ return {{repository:'stackchain/api',number:41,title:'First'}};
+ }},
+}});
+outbox.enqueue({{repository:'stackchain/api',title:'First',body:'One'}});
+outbox.enqueue({{repository:'stackchain/web',title:'Second',body:'Two'}});
+outbox.flush().then(result => process.stdout.write(JSON.stringify({{result,calls,remaining:outbox.list()}})));
+"""
+ output = run_node(script)
+
+ assert output["result"]["confirmed"][0]["number"] == 41
+ assert output["calls"] == [
+ {"url": "api/v1/repos/stackchain/api/issues", "key": "stable-1"},
+ {"url": "api/v1/repos/stackchain/web/issues", "key": "stable-2"},
+ ]
+ assert len(output["remaining"]) == 1
+ assert output["remaining"][0]["title"] == "Second"
+ assert output["remaining"][0]["status"] == "queued"
+
+
+def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
+ script = f"""
+const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+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 invalid = true;
+const calls = [];
+const outbox = createIssueOutbox({{
+ storage, createOperationId:() => 'stable-edit',
+ fetchJson:async (_url, options) => {{
+ calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
+ if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
+ return {{repository:'stackchain/api',number:42,title:'Fixed title'}};
+ }},
+}});
+const queued = outbox.enqueue({{repository:'stackchain/api',title:'Bad',body:'Context'}});
+outbox.flush().then(async () => {{
+ const attention = outbox.list()[0];
+ outbox.update(queued.id, {{...attention,title:'Fixed title'}});
+ invalid = false;
+ const result = await outbox.retry(queued.id);
+ process.stdout.write(JSON.stringify({{attention,result,calls,remaining:outbox.list()}}));
+}});
+"""
+ output = run_node(script)
+
+ assert output["attention"]["status"] == "attention"
+ assert output["attention"]["error"] == "Title is invalid"
+ assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
+ assert output["calls"][1]["body"]["title"] == "Fixed title"
+ assert output["result"]["confirmed"][0]["number"] == 42
+ assert output["remaining"] == []
+
+
+def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
+ script = f"""
+const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+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 calls = 0;
+let release;
+const gate = new Promise(resolve => {{ release = resolve; }});
+const outbox = createIssueOutbox({{
+ storage, createOperationId:() => 'single-flight',
+ fetchJson:async () => {{ calls += 1; await gate; return {{repository:'stackchain/api',number:43,title:'Once'}}; }},
+}});
+const queued = outbox.enqueue({{repository:'stackchain/api',title:'Once',body:'Context'}});
+const reconnect = outbox.flush();
+const sendNow = outbox.retry(queued.id);
+release();
+Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.stringify({{calls,results,remaining:outbox.list()}})));
+"""
+ output = run_node(script)
+
+ assert output["calls"] == 1
+ assert output["remaining"] == []
+ assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
+
+
+@pytest.mark.anyio
+async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
+ html = await dashboard()
+
+ assert '' in html
+ assert "createIssueOutbox({ storage: localStorage" in html
+ assert "issueOutbox.enqueue(captureDraft)" in html
+ assert "navigator.onLine" in html
+ assert "window.addEventListener('online', flushIssueOutbox)" in html
+ assert 'class="draft-send"' in html
+ assert 'class="draft-edit"' in html
+ assert 'Queued for sync' in html
+ assert 'Needs attention' in html
+ assert "issueOutbox.discard(item.outbox_id)" in html
+ assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index e925c43..3fe1453 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -83,6 +83,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/issue-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/pick-work.js",