diff --git a/frontend/conversation-photo-drafts.js b/frontend/conversation-photo-drafts.js
index ad052d1..dd3e1a8 100644
--- a/frontend/conversation-photo-drafts.js
+++ b/frontend/conversation-photo-drafts.js
@@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
- return function createConversationPhotoDrafts({ store, lanes }) {
+ return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
const states = {};
Object.entries(lanes || {}).forEach(([kind, lane]) => {
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
@@ -24,7 +24,11 @@
const attachments = await current.controller.serialize();
const save = current.pending.then(() => store.save(target, attachments));
current.pending = save.catch(() => null);
- try { return await save; }
+ try {
+ const saved = await save;
+ onChange();
+ return saved;
+ }
catch (error) { current.onError?.(error); throw error; }
}
@@ -67,6 +71,7 @@
const target = { ...current.target };
await current.pending;
await store.remove(target);
+ onChange();
current.restoring = true;
try { current.controller.clear(); }
finally {
diff --git a/frontend/conversation-reply-draft-store.js b/frontend/conversation-reply-draft-store.js
index 0d8b0a3..22271d4 100644
--- a/frontend/conversation-reply-draft-store.js
+++ b/frontend/conversation-reply-draft-store.js
@@ -33,10 +33,11 @@
}
return async (operation, key, value) => {
const db = await database();
- const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
+ const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
+ if (operation === 'list') return requestResult(records.getAll());
return requestResult(records.get(key));
};
}
@@ -97,7 +98,10 @@
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
if (!list.length) { await transact('delete', id); return null; }
- await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
+ await transact('put', id, {
+ id, version:1, ownerLogin, scope:recordScope, ...normalized,
+ updatedAt:Date.now(), attachments:list,
+ });
return list;
}
@@ -122,6 +126,38 @@
return true;
}
- return { save, load, remove };
+ async function list() {
+ if (!transact || draftScope !== 'conversation') return [];
+ const ownerLogin = String(getOwnerLogin() || '').trim();
+ if (!ownerLogin) return [];
+ const records = await transact('list');
+ return (Array.isArray(records) ? records : []).flatMap(record => {
+ if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
+ (record.scope && record.scope !== 'conversation') ||
+ !Array.isArray(record.attachments) || !record.attachments.length) return [];
+ const updatedAt = Number(record.updatedAt || 0);
+ if (record.kind === 'update') {
+ const notificationId = Number(record.notificationId || 0);
+ if (!Number.isInteger(notificationId) || notificationId < 1) return [];
+ return [{
+ id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
+ photo_count:record.attachments.length, updated_at:updatedAt,
+ route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
+ }];
+ }
+ const repository = String(record.repository || '');
+ const number = Number(record.number || 0);
+ if (!['issue', 'pull'].includes(record.kind) || !repository ||
+ !Number.isInteger(number) || number < 1) return [];
+ const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
+ return [{
+ id:record.id, kind:'photo-reply', label, repository, number,
+ title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
+ route:{ kind:record.kind, repository, number }, photo_store:'conversation',
+ }];
+ }).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
+ }
+
+ return { save, load, remove, list };
};
});
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index f9d00cf..a636e48 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -777,6 +777,7 @@
try {
const attachments = await searchReplyAttachmentController.serialize();
await searchReplyDraftStore.save(target, attachments);
+ void refreshPhotoDraftInbox();
} catch (error) {
qs('#search-preview-reply-status').textContent = error?.message ||
'Photos could not be saved. They remain in this preview; retry before leaving.';
@@ -1002,6 +1003,7 @@
} });
const conversationPhotoDrafts = createConversationPhotoDrafts({
store:conversationPhotoDraftStore,
+ onChange:() => { void refreshPhotoDraftInbox(); },
lanes:{
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
@@ -1310,6 +1312,10 @@
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
}
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
+ const photoDraftInbox = createPhotoDraftInbox({
+ conversation:conversationPhotoDraftStore,
+ search:searchReplyDraftStore,
+ });
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
let offlineStorageReady = await offlineWorkStore.ready();
@@ -3401,12 +3407,17 @@
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) =>
+ return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0)
);
}
- function refreshMyWorkView({ reconcileSession = true } = {}) {
+ async function refreshPhotoDraftInbox() {
+ try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false }); }
+ catch (_error) { /* Local photo inventory must not break My Work. */ }
+ }
+
+ function refreshMyWorkView({ reconcileSession = true, refreshFirstTask = true } = {}) {
lastDrafts = listDrafts();
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
const partitioned = laterWork.partition(actionableMyWork, {
@@ -3460,7 +3471,7 @@
});
mobileStartDay.render();
mobileTaskDock.updateQueues(counts);
- mobileFirstTask.refresh();
+ if (refreshFirstTask) mobileFirstTask.refresh();
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
@@ -3579,7 +3590,7 @@
return '' +
'' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '' +
'' + escapeHtml(item.title) + '' +
- '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + attempt +
+ '' + escapeHtml(photoDraftInbox.description(item)) + '' + state + attempt +
'Saved ' + escapeHtml(fmt(item.updated_at)) + '' +
'' + outboxActions + '
';
};
@@ -3618,6 +3629,9 @@
await dFS.open(item.capture_id, item.ready ? button : null);
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
+ else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
+ taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
+ }
else if (item.route) workRoute.open(item.route);
};
});
@@ -3736,6 +3750,7 @@
}
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 === 'photo-reply') await photoDraftInbox.discard(item);
else if (item) draftInbox.discard(item.id);
lastDrafts = listDrafts();
const count = qs('[data-work-count="draft"]');
@@ -5493,6 +5508,7 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
+ void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
updateDeliveryReceiptControls();
diff --git a/frontend/index.html b/frontend/index.html
index 5dcfebe..5900a92 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2264,6 +2264,7 @@
+
diff --git a/frontend/photo-draft-inbox.js b/frontend/photo-draft-inbox.js
new file mode 100644
index 0000000..72eae21
--- /dev/null
+++ b/frontend/photo-draft-inbox.js
@@ -0,0 +1,80 @@
+(function (root, factory) {
+ const createPhotoDraftInbox = factory();
+ if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
+ else root.createPhotoDraftInbox = createPhotoDraftInbox;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ function targetKey(route) {
+ if (route?.kind === 'update') {
+ const notificationId = Number(route.notification_id || 0);
+ return notificationId > 0 ? 'update:' + notificationId : '';
+ }
+ const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
+ const repository = String(route?.repository || '');
+ const number = Number(route?.number || 0);
+ return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
+ kind + ':' + repository.toLowerCase() + '#' + number : '';
+ }
+
+ function removeTarget(item) {
+ const route = item?.route || {};
+ if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
+ return {
+ kind:route.kind === 'search' ? route.target_kind : route.kind,
+ repository:route.repository,
+ number:Number(route.number),
+ };
+ }
+
+ return function createPhotoDraftInbox({ conversation, search }) {
+ let items = [];
+
+ async function refresh(existing = []) {
+ const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
+ const results = await Promise.allSettled([
+ conversation?.list?.() || [],
+ search?.list?.() || [],
+ ]);
+ items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
+ .filter(item => {
+ const key = targetKey(item?.route);
+ if (!key || occupied.has(key)) return false;
+ occupied.add(key);
+ return true;
+ })
+ .sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
+ String(left.id || '').localeCompare(String(right.id || '')));
+ return items.slice();
+ }
+
+ function list() { return items.slice(); }
+
+ async function discard(item) {
+ if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
+ return false;
+ }
+ const store = item.photo_store === 'search' ? search : conversation;
+ if (!store?.remove) return false;
+ await store.remove(removeTarget(item));
+ items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
+ return true;
+ }
+
+ function description(item) {
+ return item?.kind === 'photo-reply' ?
+ Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
+ (item?.preview || 'Unfinished draft');
+ }
+
+ function searchTarget(item) {
+ const route = item?.route || {};
+ return {
+ kind:route.target_kind, repository:route.repository,
+ number:route.number, title:item?.title,
+ };
+ }
+
+ return { refresh, list, discard, description, searchTarget };
+ };
+});
diff --git a/frontend/search-reply-draft-store.js b/frontend/search-reply-draft-store.js
index 9fa78e1..02ae246 100644
--- a/frontend/search-reply-draft-store.js
+++ b/frontend/search-reply-draft-store.js
@@ -33,10 +33,11 @@
}
return async (operation, key, value) => {
const db = await database();
- const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
+ const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
+ if (operation === 'list') return requestResult(records.getAll());
return requestResult(records.get(key));
};
}
@@ -95,7 +96,9 @@
await transact('delete', id);
return null;
}
- await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
+ await transact('put', id, {
+ id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list,
+ });
return list;
}
@@ -116,6 +119,28 @@
return true;
}
- return { save, load, remove };
+ async function list() {
+ if (!transact) return [];
+ const ownerLogin = String(getOwnerLogin() || '').trim();
+ if (!ownerLogin) return [];
+ const records = await transact('list');
+ return (Array.isArray(records) ? records : []).flatMap(record => {
+ const repository = String(record?.repository || '');
+ const number = Number(record?.number || 0);
+ if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
+ !['issue', 'pull'].includes(record.kind) || !repository ||
+ !Number.isInteger(number) || number < 1 ||
+ !Array.isArray(record.attachments) || !record.attachments.length) return [];
+ return [{
+ id:record.id, kind:'photo-reply',
+ label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
+ repository, number, title:repository + '#' + number,
+ photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
+ route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
+ }];
+ }).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
+ }
+
+ return { save, load, remove, list };
};
});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 6165458..f08ca32 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -167,6 +167,7 @@ const SHELL = [
BASE + 'static/search-reply-draft-store.js',
BASE + 'static/conversation-reply-draft-store.js',
BASE + 'static/conversation-photo-drafts.js',
+ BASE + 'static/photo-draft-inbox.js',
BASE + 'static/search-defer.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 74745c7..93bdcd6 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -41,12 +41,12 @@ FEATURE_SOURCES = {
"today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
- "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
+ "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
- "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
+ "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
),
}
CACHE_DECLARATION = re.compile(
diff --git a/tests/e2e/test_mobile_photo_draft_inbox_release.py b/tests/e2e/test_mobile_photo_draft_inbox_release.py
new file mode 100644
index 0000000..f77e9b0
--- /dev/null
+++ b/tests/e2e/test_mobile_photo_draft_inbox_release.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import os
+import threading
+from pathlib import Path
+
+import pytest
+
+if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
+ pytest.skip("packaged mobile photo-draft journey runs only in its gated CI job", allow_module_level=True)
+pytest.importorskip("playwright.sync_api")
+from playwright.sync_api import expect, sync_playwright
+
+from fake_gitea import FakeGiteaServer
+from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
+
+
+def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
+ archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
+ assert len(archives) == 1, "browser job must provide exactly one assembled release archive"
+
+ fake = FakeGiteaServer(("127.0.0.1", 0))
+ fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
+ fake_thread.start()
+ fake_url = f"http://127.0.0.1:{fake.server_port}"
+
+ try:
+ with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
+ browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
+ context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
+ page = context.new_page()
+ page.goto(origin + "/", wait_until="networkidle")
+ page.locator('input[name="device_label"]').fill("Photo draft release phone")
+ page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
+ page.locator("#submit-sign-in").click()
+ page.wait_for_url(origin + "/", wait_until="networkidle")
+
+ page.evaluate(
+ """() => new Promise((resolve, reject) => {
+ const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
+ request.onerror=()=>reject(request.error);
+ request.onupgradeneeded=()=>{
+ if (!request.result.objectStoreNames.contains('drafts')) {
+ request.result.createObjectStore('drafts',{keyPath:'id'});
+ }
+ };
+ request.onsuccess=()=>{
+ const transaction=request.result.transaction('drafts','readwrite');
+ transaction.objectStore('drafts').put({
+ id:'timmy:issue:acme%2Fmobile%3A41', version:1, ownerLogin:'timmy',
+ scope:'conversation', kind:'issue', repository:'acme/mobile', number:41,
+ updatedAt:Date.now(), attachments:[{
+ filename:'rack-label.jpg', contentType:'image/jpeg',
+ blob:new Blob(['field evidence'],{type:'image/jpeg'}), note:'Rack label',
+ }],
+ });
+ transaction.oncomplete=resolve;
+ transaction.onerror=()=>reject(transaction.error);
+ };
+ })"""
+ )
+ page.reload(wait_until="networkidle")
+ stored = page.evaluate(
+ """() => new Promise((resolve,reject)=>{
+ const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
+ request.onerror=()=>reject(request.error);
+ request.onsuccess=()=>{
+ const values=request.result.transaction('drafts','readonly').objectStore('drafts').getAll();
+ values.onsuccess=()=>resolve(values.result.map(item=>({ownerLogin:item.ownerLogin,kind:item.kind,number:item.number})));
+ values.onerror=()=>reject(values.error);
+ };
+ })"""
+ )
+ assert stored == [{"ownerLogin": "timmy", "kind": "issue", "number": 41}]
+ inventory = page.evaluate(
+ """() => createConversationReplyDraftStore({indexedDB,getOwnerLogin:()=> 'timmy'}).list()"""
+ )
+ assert len(inventory) == 1
+ expect(page.locator('[data-work-count="draft"]')).to_have_text("1", timeout=10_000)
+ page.locator('[data-mobile-task="queues"]').click()
+ page.locator('[data-mobile-queue="draft"]').click()
+
+ card = page.locator(".draft-card", has_text="acme/mobile#41")
+ expect(card).to_contain_text("1 saved photo")
+ resume = card.get_by_role("button", name="Resume draft")
+ bounds = resume.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+
+ resume.dispatch_event("click")
+ expect(page.locator("#issue-sheet")).to_be_visible()
+ expect(page.locator("#issue-attachment-preview")).to_be_visible()
+ expect(page.locator("#issue-attachment-meta")).to_contain_text("rack-label.jpg")
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ browser.close()
+ finally:
+ fake.shutdown()
+ fake.server_close()
+ fake_thread.join(timeout=5)
diff --git a/tests/test_conversation_photo_drafts.py b/tests/test_conversation_photo_drafts.py
index fc02fbd..ecf6f9b 100644
--- a/tests/test_conversation_photo_drafts.py
+++ b/tests/test_conversation_photo_drafts.py
@@ -79,6 +79,50 @@ const photos = [
}
+def test_store_lists_only_confirmed_accounts_conversation_photo_drafts_without_blobs():
+ script = f"""
+const createStore = require({json.dumps(str(STORE))});
+const records = new Map([
+ ['timmy:issue:stackchain%2Fdashboard%3A7', {{id:'a',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'stackchain/dashboard',number:7,updatedAt:100,attachments:[{{filename:'rack.jpg',blob:new Blob(['secret'])}},{{filename:'label.jpg',blob:new Blob(['secret'])}}]}},],
+ ['timmy:update:8', {{id:'b',version:1,ownerLogin:'timmy',scope:'conversation',kind:'update',notificationId:8,updatedAt:200,attachments:[{{filename:'alert.jpg',blob:new Blob(['secret'])}}]}},],
+ ['alexander:pull:o%2Fr%3A9', {{id:'c',version:1,ownerLogin:'alexander',scope:'conversation',kind:'pull',repository:'o/r',number:9,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},],
+ ['broken', {{id:'broken',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'',number:0,updatedAt:400,attachments:[{{filename:'bad.jpg',blob:new Blob(['bad'])}}]}},],
+]);
+const transaction = async (operation, key, value) => {{
+ if (operation === 'list') return Array.from(records.values()).map(value => structuredClone(value));
+}};
+const store = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
+(async()=>{{
+ const drafts=await store.list();
+ process.stdout.write(JSON.stringify(drafts));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert json.loads(run_node(script)) == [
+ {
+ "id": "b",
+ "kind": "photo-reply",
+ "label": "Update photos",
+ "title": "Update #8",
+ "photo_count": 1,
+ "updated_at": 200,
+ "route": {"kind": "update", "notification_id": 8},
+ "photo_store": "conversation",
+ },
+ {
+ "id": "a",
+ "kind": "photo-reply",
+ "label": "Issue photos",
+ "repository": "stackchain/dashboard",
+ "number": 7,
+ "title": "stackchain/dashboard#7",
+ "photo_count": 2,
+ "updated_at": 100,
+ "route": {"kind": "issue", "repository": "stackchain/dashboard", "number": 7},
+ "photo_store": "conversation",
+ },
+ ]
+
+
def test_store_isolates_today_progress_photos_from_conversation_photos():
script = f"""
const createStore = require({json.dumps(str(STORE))});
@@ -109,6 +153,24 @@ const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
}
+def test_coordinator_notifies_inventory_after_checkpoint_and_completion():
+ script = f"""
+const createCoordinator = require({json.dumps(str(COORDINATOR))});
+const changes=[];
+let current=[{{filename:'proof.jpg',blob:new Blob(['proof'])}}];
+const store={{save:async()=>null,load:async()=>null,remove:async()=>null}};
+const controller={{serialize:async()=>current,restore:()=>{{}},clear:()=>{{current=[];}}}};
+const drafts=createCoordinator({{store,lanes:{{issue:{{controller}}}},onChange:()=>changes.push('changed')}});
+(async()=>{{
+ await drafts.open('issue',{{kind:'issue',repository:'o/r',number:7}});
+ await drafts.checkpoint('issue');
+ await drafts.complete('issue');
+ process.stdout.write(JSON.stringify(changes));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert json.loads(run_node(script)) == ["changed", "changed"]
+
+
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
diff --git a/tests/test_photo_draft_inbox.py b/tests/test_photo_draft_inbox.py
new file mode 100644
index 0000000..599f1e2
--- /dev/null
+++ b/tests/test_photo_draft_inbox.py
@@ -0,0 +1,111 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+MODULE = Path(__file__).parents[1] / "frontend" / "photo-draft-inbox.js"
+INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
+DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
+WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
+BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
+
+
+def run_node(script: str):
+ completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
+ return json.loads(completed.stdout)
+
+
+def test_photo_draft_inbox_combines_stores_without_duplicate_target_cards_and_discards_exact_bundle():
+ script = f"""
+const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
+const removed=[];
+const conversation={{
+ list:async()=>[
+ {{id:'conversation-issue',kind:'photo-reply',photo_store:'conversation',title:'o/r#7',updated_at:30,route:{{kind:'issue',repository:'o/r',number:7}}}},
+ {{id:'conversation-update',kind:'photo-reply',photo_store:'conversation',title:'Update #8',updated_at:20,route:{{kind:'update',notification_id:8}}}},
+ ],
+ remove:async target=>removed.push(['conversation',target]),
+}};
+const search={{
+ list:async()=>[
+ {{id:'search-pull',kind:'photo-reply',photo_store:'search',title:'o/r#9',updated_at:40,route:{{kind:'search',target_kind:'pull',repository:'o/r',number:9}}}},
+ ],
+ remove:async target=>removed.push(['search',target]),
+}};
+const inbox=createPhotoDraftInbox({{conversation,search}});
+(async()=>{{
+ const drafts=await inbox.refresh([{{kind:'issue-comment',route:{{kind:'issue',repository:'o/r',number:7}}}}]);
+ const description=inbox.description({{kind:'photo-reply',photo_count:2}});
+ const searchTarget=inbox.searchTarget(drafts[0]);
+ await inbox.discard(drafts[0]);
+ process.stdout.write(JSON.stringify({{drafts,description,searchTarget,remaining:inbox.list(),removed}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert run_node(script) == {
+ "drafts": [
+ {
+ "id": "search-pull",
+ "kind": "photo-reply",
+ "photo_store": "search",
+ "title": "o/r#9",
+ "updated_at": 40,
+ "route": {"kind": "search", "target_kind": "pull", "repository": "o/r", "number": 9},
+ },
+ {
+ "id": "conversation-update",
+ "kind": "photo-reply",
+ "photo_store": "conversation",
+ "title": "Update #8",
+ "updated_at": 20,
+ "route": {"kind": "update", "notification_id": 8},
+ },
+ ],
+ "description": "2 saved photos",
+ "searchTarget": {"kind": "pull", "repository": "o/r", "number": 9, "title": "o/r#9"},
+ "remaining": [
+ {
+ "id": "conversation-update",
+ "kind": "photo-reply",
+ "photo_store": "conversation",
+ "title": "Update #8",
+ "updated_at": 20,
+ "route": {"kind": "update", "notification_id": 8},
+ }
+ ],
+ "removed": [["search", {"kind": "pull", "repository": "o/r", "number": 9}]],
+ }
+
+
+def test_photo_draft_inbox_keeps_available_store_when_other_inventory_fails():
+ script = f"""
+const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
+const inbox=createPhotoDraftInbox({{
+ conversation:{{list:async()=>{{throw new Error('IndexedDB unavailable')}}}},
+ search:{{list:async()=>[{{id:'safe',route:{{kind:'search',target_kind:'issue',repository:'o/r',number:2}},updated_at:1}}]}},
+}});
+(async()=>process.stdout.write(JSON.stringify(await inbox.refresh([]))))().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert run_node(script) == [
+ {"id": "safe", "route": {"kind": "search", "target_kind": "issue", "repository": "o/r", "number": 2}, "updated_at": 1}
+ ]
+
+
+def test_mobile_my_work_discovers_reopens_and_discards_photo_only_reply_drafts():
+ index = INDEX.read_text()
+ dashboard = DASHBOARD.read_text()
+ worker = WORKER.read_text()
+ bundle = BUNDLE.read_text()
+
+ assert '' in index
+ assert "BASE + 'static/photo-draft-inbox.js'" in worker
+ assert '"static/photo-draft-inbox.js"' in bundle
+ assert "const photoDraftInbox = createPhotoDraftInbox({" in dashboard
+ assert "draftInbox.list().concat(photoDraftInbox.list(), unfiled)" in dashboard
+ assert "await photoDraftInbox.refresh(draftInbox.list())" in dashboard
+ assert "refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false })" in dashboard
+ assert "void refreshPhotoDraftInbox();" in dashboard
+ assert "onChange:() => { void refreshPhotoDraftInbox(); }" in dashboard
+ assert "await searchReplyDraftStore.save(target, attachments);\n void refreshPhotoDraftInbox();" in dashboard
+ assert "photoDraftInbox.description(item)" in dashboard
+ assert "photoDraftInbox.searchTarget(item)" in dashboard
+ assert "await photoDraftInbox.discard(item)" in dashboard
diff --git a/tests/test_search_reply_draft_store.py b/tests/test_search_reply_draft_store.py
index 2491264..2c3de83 100644
--- a/tests/test_search_reply_draft_store.py
+++ b/tests/test_search_reply_draft_store.py
@@ -65,6 +65,32 @@ const photo = new Blob(['field-evidence'], {{type:'image/webp'}});
}
+def test_search_store_lists_only_confirmed_accounts_photo_drafts_as_metadata():
+ script = f"""
+const createStore = require({json.dumps(str(STORE))});
+const records = [
+ {{id:'mine',version:1,ownerLogin:'timmy',kind:'pull',repository:'stackchain/dashboard',number:19,updatedAt:220,attachments:[{{filename:'proof.jpg',blob:new Blob(['secret'])}}]}},
+ {{id:'other',version:1,ownerLogin:'alexander',kind:'issue',repository:'stackchain/private',number:3,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},
+ {{id:'empty',version:1,ownerLogin:'timmy',kind:'issue',repository:'stackchain/dashboard',number:20,updatedAt:400,attachments:[]}},
+];
+const transaction=async operation=>operation === 'list' ? records.map(value=>structuredClone(value)) : null;
+const store=createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
+(async()=>process.stdout.write(JSON.stringify(await store.list())))().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert json.loads(run_node(script)) == [{
+ "id": "mine",
+ "kind": "photo-reply",
+ "label": "Search PR photos",
+ "repository": "stackchain/dashboard",
+ "number": 19,
+ "title": "stackchain/dashboard#19",
+ "photo_count": 1,
+ "updated_at": 220,
+ "route": {"kind": "search", "target_kind": "pull", "repository": "stackchain/dashboard", "number": 19},
+ "photo_store": "search",
+ }]
+
+
def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 03c3683..f0bcbc6 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -1389,6 +1389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-reply-draft-store.js",
"/dashboard/static/conversation-reply-draft-store.js",
"/dashboard/static/conversation-photo-drafts.js",
+ "/dashboard/static/photo-draft-inbox.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",