diff --git a/README.md b/README.md
index 2704da8..4eb64cd 100644
--- a/README.md
+++ b/README.md
@@ -87,10 +87,14 @@ saved time and renders this snapshot read-only; opening live details, pagination
and server mutations remain disabled until reconnection. **Clear offline work
data** deletes the snapshot, and opting out deletes it automatically.
-API responses and mutations are never cached by the service worker or queued;
-submissions still require connectivity. Service-worker upgrades are atomic and
-remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on
-the same origin.
+API responses and mutations are never cached by the service worker. New issue captures,
+issue comments, pull-request comments, and unread-update replies use bounded local
+outboxes when connectivity or a retryable server failure prevents delivery. Drafts
+shows queued and needs-attention messages with explicit send/discard controls; reconnect
+flushes messages sequentially with their original idempotency keys. State-sensitive
+actions such as reviews, merges, closures, labels, milestones, and assignments are never
+queued. Service-worker upgrades are atomic and remove only older
+`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
Run the test suite with:
diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
new file mode 100644
index 0000000..e26cb36
--- /dev/null
+++ b/frontend/authored-outbox.js
@@ -0,0 +1,133 @@
+function createAuthoredOutbox({ storage, fetchJson, createOperationId, now = () => Date.now(), maxItems = 50 }) {
+ const storageKey = 'stackchain.authored-outbox.v1';
+ const makeId = createOperationId || (() =>
+ globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
+ );
+ const pending = new Map();
+ const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
+
+ 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 && supportedKinds.has(item.kind));
+ } catch (_error) { return []; }
+ }
+
+ function write(items) {
+ storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
+ }
+
+ function enqueue(message) {
+ if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
+ const items = read();
+ const requestedOperationId = String(message.operationId || '').slice(0, 128);
+ const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
+ if (existing) return { ...existing };
+ if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
+ const id = String(requestedOperationId || makeId()).slice(0, 128);
+ const item = {
+ id,
+ operationId: requestedOperationId || id,
+ kind: message.kind,
+ repository: String(message.repository || ''),
+ number: Number(message.number || 0),
+ notificationId: Number(message.notificationId || 0),
+ body: String(message.body || ''),
+ status: 'queued',
+ queuedAt: Number(now()),
+ };
+ items.push(item);
+ write(items);
+ return item;
+ }
+
+ function update(id, changes) {
+ let updated = null;
+ write(read().map(item => {
+ if (item.id !== id) return item;
+ const body = String(changes?.body ?? item.body);
+ updated = {
+ ...item,
+ body,
+ operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
+ 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;
+ }
+
+ function endpoint(item) {
+ if (item.kind === 'update-reply') {
+ return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
+ }
+ const repository = item.repository.split('/').map(encodeURIComponent).join('/');
+ const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
+ return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
+ }
+
+ async function sendItem(item) {
+ if (pending.has(item.id)) return pending.get(item.id);
+ const request = (async () => {
+ try {
+ const result = await fetchJson(endpoint(item), {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': item.operationId,
+ },
+ body: JSON.stringify({ body: item.body }),
+ });
+ discard(item.id);
+ return { result };
+ } catch (error) {
+ const status = Number(error?.status || 0);
+ const permanent = status >= 400 && status < 500;
+ if (permanent) {
+ write(read().map(candidate => candidate.id === item.id ? {
+ ...candidate,
+ status: 'attention',
+ error: String(error.message || 'Message needs attention').slice(0, 240),
+ } : candidate));
+ }
+ return { error, transient: !permanent };
+ }
+ })();
+ 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 outcome = await sendItem(item);
+ if (outcome.result) confirmed.push(outcome.result);
+ if (outcome.transient) break;
+ }
+ return { confirmed, remaining: read() };
+ }
+
+ async function retry(id) {
+ const item = read().find(candidate => candidate.id === id);
+ if (!item) return { confirmed: [], remaining: read() };
+ const queued = item.status === 'attention' ? update(id, item) : item;
+ const outcome = await sendItem(queued);
+ return { confirmed: outcome.result ? [outcome.result] : [], remaining: read() };
+ }
+
+ return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
diff --git a/frontend/drafts.js b/frontend/drafts.js
index 2d974b1..e8473dd 100644
--- a/frontend/drafts.js
+++ b/frontend/drafts.js
@@ -118,6 +118,32 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
} catch (_error) { return []; }
}
+ function parseAuthoredOutbox(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.body === 'string')
+ .map(item => {
+ const isUpdate = item.kind === 'update-reply';
+ const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
+ const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
+ return {
+ id: 'stackchain.authored-outbox.v1:' + item.id,
+ outbox_id: item.id,
+ kind: 'authored-outbox',
+ status: item.status === 'attention' ? 'attention' : 'queued',
+ label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
+ repository: isUpdate ? '' : item.repository,
+ title: target,
+ preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
+ updated_at: Number(item.queuedAt || 0),
+ route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
+ { kind:routeKind, repository:item.repository, number:item.number },
+ };
+ });
+ } catch (_error) { return []; }
+ }
+
function list() {
const index = readIndex();
const seen = new Set();
@@ -132,6 +158,11 @@ function createDraftInbox({ storage, now = () => Date.now() }) {
seen.add(key);
return;
}
+ if (key === 'stackchain.authored-outbox.v1') {
+ parseAuthoredOutbox(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 404a1e6..461ebbc 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -719,6 +719,7 @@ textarea { resize: vertical; min-height: 120px; }
+
@@ -830,6 +831,7 @@ textarea { resize: vertical; min-height: 120px; }
});
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
+ const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson });
const shareParams = new URLSearchParams(location.search);
const sharedLaunch = {
title: shareParams.get('title') || '',
@@ -985,6 +987,7 @@ textarea { resize: vertical; min-height: 120px; }
const notificationReplier = createNotificationReplier({
post: postNotificationReply,
storage: localStorage,
+ authoredOutbox,
onStatus: message => { qs('#update-reply-status').textContent = message; },
});
const notificationReader = createNotificationReader({
@@ -1251,13 +1254,18 @@ textarea { resize: vertical; min-height: 120px; }
function renderDrafts() {
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 outboxActions = item.kind === 'issue-outbox' ?
'' +
'' +
'' :
+ item.kind === 'authored-outbox' ?
+ '' +
+ '' +
+ '' :
'' +
'';
- const state = item.kind === 'issue-outbox' ?
+ const state = isOutbox ?
'' + (item.status === 'attention' ? 'Needs attention' : 'Queued for sync') + '' : '';
return '' +
'' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' +
@@ -1290,7 +1298,8 @@ textarea { resize: vertical; min-height: 120px; }
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item?.outbox_id) return;
button.disabled = true;
- applyOutboxResult(await issueOutbox.retry(item.outbox_id));
+ if (item.kind === 'authored-outbox') applyAuthoredOutboxResult(await authoredOutbox.retry(item.outbox_id));
+ else applyOutboxResult(await issueOutbox.retry(item.outbox_id));
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
@@ -1298,6 +1307,7 @@ textarea { resize: vertical; min-height: 120px; }
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);
+ else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
lastDrafts = draftInbox.list();
const count = qs('[data-work-count="draft"]');
@@ -1970,6 +1980,25 @@ textarea { resize: vertical; min-height: 120px; }
applyOutboxResult(await issueOutbox.flush());
}
+ function applyAuthoredOutboxResult(result) {
+ refreshMyWorkView();
+ const attention = (result.remaining || []).some(item => item.status === 'attention');
+ qs('#my-work-action-status').textContent = result.confirmed?.length ?
+ result.confirmed.length + ' queued message' + (result.confirmed.length === 1 ? '' : 's') + ' sent.' :
+ attention ? 'A queued message needs attention. Open Drafts to edit or discard it.' :
+ 'Message queued for sync when the connection returns.';
+ }
+
+ async function flushAuthoredOutbox() {
+ if (!navigator.onLine || !authoredOutbox.list().length) return;
+ applyAuthoredOutboxResult(await authoredOutbox.flush());
+ }
+
+ function canQueueMessage(error) {
+ const status = Number(error?.status || 0);
+ return !status || status >= 500;
+ }
+
function inlineAnchor(target) {
return {
path: target.dataset.reviewFilename,
@@ -2802,8 +2831,17 @@ textarea { resize: vertical; min-height: 120px; }
qs('#issue-comment').value = '';
qs('#issue-comment-status').textContent = 'Comment posted.';
} catch (error) {
- qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
- qs('#issue-comment').focus();
+ if (canQueueMessage(error)) {
+ const operationId = localStorage.getItem('stackchain.issue-comment.v1:' + selectedIssue.repository + '#' + selectedIssue.number + ':operation');
+ authoredOutbox.enqueue({ kind:'issue-comment', repository:selectedIssue.repository,
+ number:selectedIssue.number, body, operationId });
+ qs('#issue-comment').value = '';
+ qs('#issue-comment-status').textContent = 'Queued for sync when the connection returns.';
+ refreshMyWorkView();
+ } else {
+ qs('#issue-comment-status').textContent = error.message + ' Your draft is safe; retry.';
+ qs('#issue-comment').focus();
+ }
} finally {
button.disabled = false;
}
@@ -2894,8 +2932,17 @@ textarea { resize: vertical; min-height: 120px; }
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
- qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
- qs('#pull-comment').focus();
+ if (canQueueMessage(error)) {
+ const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation');
+ authoredOutbox.enqueue({ kind:'pull-comment', repository:selectedPull.repository,
+ number:selectedPull.number, body, operationId });
+ qs('#pull-comment').value = '';
+ qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.';
+ refreshMyWorkView();
+ } else {
+ qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
+ qs('#pull-comment').focus();
+ }
} finally {
button.disabled = false;
}
@@ -2948,7 +2995,11 @@ textarea { resize: vertical; min-height: 120px; }
qs('#send-update-reply').disabled = true;
const result = await notificationReplier.submit(selectedUpdate, body);
qs('#send-update-reply').disabled = false;
- if (result) {
+ if (result?.queued) {
+ qs('#update-reply').value = '';
+ refreshMyWorkView();
+ qs('#my-work-action-status').textContent = 'Reply queued for sync.';
+ } else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
qs('#mark-update-read-next').focus();
@@ -3152,6 +3203,7 @@ textarea { resize: vertical; min-height: 120px; }
window.addEventListener('offline', showOfflineStatus);
window.addEventListener('online', reconnectLiveData);
window.addEventListener('online', flushIssueOutbox);
+ window.addEventListener('online', flushAuthoredOutbox);
qs('#refresh').addEventListener('click', load);
qs('#start-work-session').addEventListener('click', () => {
@@ -3237,6 +3289,7 @@ textarea { resize: vertical; min-height: 120px; }
}
contextPoller.start();
flushIssueOutbox();
+ flushAuthoredOutbox();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
});
diff --git a/frontend/my-work.js b/frontend/my-work.js
index c1c08a2..0875cb1 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -344,7 +344,7 @@ function createNotificationReader({
}
function createNotificationReplier({
- post, storage, onStatus,
+ post, storage, onStatus, authoredOutbox,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pending = false;
@@ -380,7 +380,15 @@ function createNotificationReplier({
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return result;
- } catch (_error) {
+ } catch (error) {
+ const status = Number(error?.status || 0);
+ if (authoredOutbox && (!status || status >= 500)) {
+ authoredOutbox.enqueue({
+ kind: 'update-reply', notificationId: item.notification_id, body, operationId,
+ });
+ onStatus('Queued for sync when the connection returns.');
+ return { queued: true };
+ }
onStatus('Could not send reply. Your draft is safe; retry.');
return false;
} finally {
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index aa21115..d735ae8 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE = 'stackchain-dashboard-shell-v5';
+const CACHE = 'stackchain-dashboard-shell-v6';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const BASE = new URL('./', self.location.href).pathname;
const SHELL = [
@@ -12,6 +12,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/issue-outbox.js',
+ BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/pick-work.js',
diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py
new file mode 100644
index 0000000..98071aa
--- /dev/null
+++ b/tests/test_authored_outbox.py
@@ -0,0 +1,136 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from src.views import dashboard
+
+
+OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-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_authored_outbox_persists_each_message_kind_and_flushes_sequentially():
+ script = f"""
+const createAuthoredOutbox = 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)}};
+const calls = [];
+const outbox = createAuthoredOutbox({{
+ storage, now:() => 1234,
+ fetchJson:async (url, options) => {{
+ calls.push({{url,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
+ return {{id:calls.length}};
+ }},
+}});
+outbox.enqueue({{kind:'issue-comment',repository:'stackchain/api',number:7,body:'Issue note',operationId:'issue-op'}});
+outbox.enqueue({{kind:'pull-comment',repository:'stackchain/web',number:8,body:'PR note',operationId:'pull-op'}});
+outbox.enqueue({{kind:'update-reply',notificationId:9,body:'Update note',operationId:'update-op'}});
+const persisted = createAuthoredOutbox({{storage,fetchJson:outbox.fetchJson}}).list();
+outbox.flush().then(result => process.stdout.write(JSON.stringify({{persisted,calls,result,remaining:outbox.list()}})));
+"""
+ output = run_node(script)
+
+ assert [item["kind"] for item in output["persisted"]] == [
+ "issue-comment", "pull-comment", "update-reply"
+ ]
+ assert [item["operationId"] for item in output["persisted"]] == ["issue-op", "pull-op", "update-op"]
+ assert output["calls"] == [
+ {"url": "api/v1/repos/stackchain/api/issues/7/comments", "key": "issue-op", "body": {"body": "Issue note"}},
+ {"url": "api/v1/repos/stackchain/web/pulls/8/comments", "key": "pull-op", "body": {"body": "PR note"}},
+ {"url": "api/v1/notifications/9/reply", "key": "update-op", "body": {"body": "Update note"}},
+ ]
+ assert len(output["result"]["confirmed"]) == 3
+ assert output["remaining"] == []
+
+
+def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
+ script = f"""
+const createAuthoredOutbox = 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 phase = 'permanent';
+const calls = [];
+const outbox = createAuthoredOutbox({{
+ storage,
+ fetchJson:async (_url, options) => {{
+ const body = JSON.parse(options.body).body; calls.push(body);
+ if (body === 'Bad' && phase === 'permanent') {{ const e = new Error('Reply rejected'); e.status=422; throw e; }}
+ if (body === 'Later' && phase === 'transient') {{ const e = new Error('Offline'); e.status=503; throw e; }}
+ return {{id:calls.length}};
+ }},
+}});
+const bad=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Bad',operationId:'a'}});
+outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Good',operationId:'b'}});
+outbox.flush().then(async first => {{
+ phase='transient';
+ outbox.enqueue({{kind:'update-reply',notificationId:3,body:'Later',operationId:'c'}});
+ const second=await outbox.flush();
+ process.stdout.write(JSON.stringify({{first,second,calls,remaining:outbox.list(),badId:bad.id}}));
+}});
+"""
+ output = run_node(script)
+
+ assert output["calls"] == ["Bad", "Good", "Later"]
+ assert len(output["first"]["confirmed"]) == 1
+ assert output["remaining"][0]["id"] == output["badId"]
+ assert output["remaining"][0]["status"] == "attention"
+ assert output["remaining"][0]["error"] == "Reply rejected"
+ assert output["remaining"][1]["status"] == "queued"
+
+
+def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
+ script = f"""
+const createAuthoredOutbox = 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 release; const gate=new Promise(resolve=>release=resolve); let calls=0; let sequence=0;
+const outbox=createAuthoredOutbox({{
+ storage, createOperationId:()=> 'new-' + (++sequence),
+ fetchJson:async()=>{{calls++; await gate; return {{id:1}};}},
+}});
+const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Old',operationId:'old'}});
+const edited=outbox.update(item.id,{{body:'New'}});
+const flush=outbox.flush(); const retry=outbox.retry(item.id); release();
+Promise.all([flush,retry]).then(results=>process.stdout.write(JSON.stringify({{edited,calls,results,remaining:outbox.list()}})));
+"""
+ output = run_node(script)
+
+ assert output["edited"]["body"] == "New"
+ assert output["edited"]["operationId"] == "new-1"
+ assert output["calls"] == 1
+ assert output["remaining"] == []
+
+
+def test_authored_outbox_deduplicates_repeated_queue_attempts_by_operation_id():
+ script = f"""
+const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
+const values = new Map();
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const outbox=createAuthoredOutbox({{storage}});
+const first=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
+const second=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Same',operationId:'stable'}});
+process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
+"""
+ output = run_node(script)
+
+ assert output["first"] == output["second"]
+ assert len(output["items"]) == 1
+
+
+@pytest.mark.anyio
+async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
+ html = await dashboard()
+
+ assert '' in html
+ assert "createAuthoredOutbox({ storage: localStorage" in html
+ assert "window.addEventListener('online', flushAuthoredOutbox)" in html
+ assert "authoredOutbox.enqueue" in html
+ assert "authoredOutbox.retry(item.outbox_id)" in html
+ assert "authoredOutbox.discard(item.outbox_id)" in html
+ assert "if (result?.queued)" in html
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 3fe1453..bcbc9e6 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -84,6 +84,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/issue-outbox.js",
+ "/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/pick-work.js",