diff --git a/README.md b/README.md
index c569e3c..6a2bbf4 100644
--- a/README.md
+++ b/README.md
@@ -19,12 +19,8 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
-Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
-For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
-failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
-admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
-localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
-either stage. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
+Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary.
+For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
index bc37fca..9dbbb33 100644
--- a/frontend/authored-outbox.js
+++ b/frontend/authored-outbox.js
@@ -6,6 +6,28 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
+ function messageAttachments(message) {
+ const values = Array.isArray(message?.attachments) ? message.attachments :
+ (Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : []));
+ return values.filter(Boolean).slice(0, 5);
+ }
+
+ function attachmentMetadata(value) {
+ return {
+ filename: String(value.filename || ''),
+ contentType: String(value.contentType || ''),
+ stored: true,
+ };
+ }
+
+ function durableAttachment(value) {
+ return {
+ filename: String(value.filename || ''),
+ contentType: String(value.contentType || ''),
+ ...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
+ };
+ }
+
function checklistOperation(value) {
const action = String(value?.action || '');
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
@@ -90,6 +112,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
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 attachments = messageAttachments(message);
const item = {
id,
operationId: requestedOperationId || id,
@@ -102,13 +125,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
- ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
- attachment: {
- filename: String(message.attachment.filename || ''),
- contentType: String(message.attachment.contentType || ''),
- stored: true,
- },
- } : {}),
+ ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
+ (attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
+ { attachments:attachments.map(attachmentMetadata) }) : {}),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
@@ -140,7 +159,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const previousItems = read();
const item = enqueue(message, false);
- if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
+ const attachments = messageAttachments(message);
+ if (attachments.length && ['update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
@@ -148,14 +168,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
- const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
+ const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
...candidate,
- attachment: {
- filename: String(message.attachment.filename || ''),
- contentType: String(message.attachment.contentType || ''),
- ...(message.attachment.blob ? { blob: message.attachment.blob } :
- { data: String(message.attachment.data || '') }),
- },
+ ...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
+ { attachments:attachments.map(durableAttachment) }),
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 5b2cc2a..8576c89 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -186,10 +186,15 @@ function createIssueSyncStore({
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
+ const preservedAttachments = current?.operationId === item?.operationId &&
+ current?.attachments?.every(value => value?.data || value?.blob) &&
+ item?.attachments?.every(value => value?.stored && !value.data && !value.blob)
+ ? { attachments:current.attachments } : {};
const next = current && current.operationId === item.operationId ? {
- ...item, ...preservedAttachment,
+ ...item, ...preservedAttachment, ...preservedAttachments,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
+ ...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
} : { ...item };
await records.put(next);
return next;
@@ -508,31 +513,65 @@ function createBackgroundIssueSync({
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
}
+ function conversationAttachments(item) {
+ return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean);
+ }
+
+ async function uploadConversationAttachments(item, url) {
+ let current = item;
+ const attachments = conversationAttachments(current);
+ const markdowns = Array.isArray(current.attachmentMarkdowns) ?
+ current.attachmentMarkdowns.slice(0, attachments.length) :
+ (current.attachmentMarkdown ? [current.attachmentMarkdown] : []);
+ for (let index = markdowns.length; index < attachments.length; index += 1) {
+ const uploaded = await requestStage(current, url, {
+ method:'POST',
+ headers:{
+ Accept:'application/json',
+ 'Idempotency-Key':stageOperationId(
+ current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
+ ),
+ },
+ body:attachmentMultipart(attachments[index]),
+ });
+ const markdown = String(uploaded?.markdown || '');
+ if (!markdown) {
+ const error = new Error('The server did not confirm the screenshot upload.');
+ error.status = 422;
+ throw error;
+ }
+ markdowns.push(markdown);
+ await checkpointClaim(current, stored => ({
+ ...stored, attachmentMarkdowns:markdowns.slice(),
+ ...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
+ }));
+ current = {
+ ...current, attachmentMarkdowns:markdowns.slice(),
+ ...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
+ };
+ }
+ return {
+ current,
+ markdown:markdowns.map((value, index) =>
+ evidenceMarkdown(attachments[index], value, index)).join('\n\n'),
+ };
+ }
+
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
- let attachmentMarkdown = current.attachmentMarkdown;
- if (current.attachment && !attachmentMarkdown) {
- const uploaded = await requestStage(current,
- base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
- method:'POST',
- headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
- body:attachmentMultipart(current.attachment),
- });
- attachmentMarkdown = String(uploaded?.markdown || '');
- if (!attachmentMarkdown) {
- const error = new Error('The server did not confirm the screenshot upload.');
- error.status = 422;
- throw error;
- }
- await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
- current = { ...current, attachmentMarkdown };
+ let attachmentMarkdown = '';
+ if (conversationAttachments(current).length) {
+ const uploaded = await uploadConversationAttachments(current,
+ base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments');
+ current = uploaded.current;
+ attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
const replyBody = attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
const options = authoredRequest('', { ...current, body:replyBody }).options;
- if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
+ if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
@@ -638,28 +677,9 @@ function createBackgroundIssueSync({
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
- let attachmentMarkdown = item.attachmentMarkdown;
- if (!attachmentMarkdown) {
- const uploaded = await requestStage(
- item,
- base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
- {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
- },
- body: attachmentMultipart(item.attachment),
- },
- );
- attachmentMarkdown = String(uploaded?.markdown || '');
- if (!attachmentMarkdown) {
- const error = new Error('The server did not confirm the screenshot upload.');
- error.status = 422;
- throw error;
- }
- await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
- }
+ const uploaded = await uploadConversationAttachments(item,
+ base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments');
+ const attachmentMarkdown = uploaded.markdown;
const text = String(item.body || '').trim();
return requestStage(
item,
@@ -676,24 +696,10 @@ function createBackgroundIssueSync({
}
async function deliverUpdateScreenshotReply(item) {
- let current = item;
- let attachmentMarkdown = current.attachmentMarkdown;
- if (!attachmentMarkdown) {
- const uploaded = await requestStage(current,
- base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
- method:'POST',
- headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
- body:attachmentMultipart(current.attachment),
- });
- attachmentMarkdown = String(uploaded?.markdown || '');
- if (!attachmentMarkdown) {
- const error = new Error('The server did not confirm the screenshot upload.');
- error.status = 422;
- throw error;
- }
- await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
- current = { ...current, attachmentMarkdown };
- }
+ const uploaded = await uploadConversationAttachments(item,
+ base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
+ const current = uploaded.current;
+ const attachmentMarkdown = uploaded.markdown;
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
@@ -708,8 +714,8 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item);
try {
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
- item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
- item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
+ item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
+ conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index db12b5a..4b195d9 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -495,6 +495,7 @@
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
});
const issueAttachmentController = issueAttachment.mount({
+ maxFiles: 5,
input: qs('#issue-attachment'),
inputs: [qs('#take-issue-comment-photo'), qs('#issue-attachment')],
preview: qs('#issue-attachment-preview'),
@@ -526,6 +527,7 @@
},
});
const pullAttachmentController = issueAttachment.mount({
+ maxFiles: 5,
input: qs('#pull-attachment'),
inputs: [qs('#take-pull-comment-photo'), qs('#pull-attachment')],
preview: qs('#pull-attachment-preview'),
@@ -548,6 +550,7 @@
},
});
const updateReplyAttachmentController = issueAttachment.mount({
+ maxFiles: 5,
input: qs('#update-reply-attachment'),
inputs: [qs('#take-update-reply-photo'), qs('#update-reply-attachment')],
preview: qs('#update-reply-attachment-preview'),
diff --git a/frontend/index.html b/frontend/index.html
index 16473e7..8a521f8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -665,7 +665,7 @@
-
+
![Selected screenshot preview]()
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index ad1f899..9e5eb9d 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v110';
+const CACHE = 'stackchain-dashboard-shell-v111';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py
index 7877217..886c764 100644
--- a/tests/test_authored_outbox.py
+++ b/tests/test_authored_outbox.py
@@ -804,3 +804,35 @@ const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',background
"filename": "proof.webp", "contentType": "image/webp", "stored": True
}
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
+
+
+def test_conversation_photo_bundle_keeps_only_metadata_in_localstorage_and_all_blobs_in_indexeddb():
+ script = f"""
+const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
+const values=new Map();const mirrors=[];
+const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
+ reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
+}}}});
+const photo=(name,bytes)=>({{filename:name,contentType:'image/jpeg',blob:new Blob([bytes],{{type:'image/jpeg'}})}});
+(async()=>{{
+ await outbox.enqueueDurably({{kind:'update-reply-read',notificationId:943,body:'Before and after',
+ operationId:'reply-bundle-943',attachments:[photo('before.jpg','PRIVATE-BEFORE'),photo('after.jpg','PRIVATE-AFTER')]}});
+ const local=values.get('stackchain.authored-outbox.v1');
+ const durable=mirrors[0][0].attachments;
+ process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachments,
+ durable:await Promise.all(durable.map(async value=>({{filename:value.filename,isBlob:value.blob instanceof Blob,text:await value.blob.text()}})))}}));
+}})();
+"""
+ output = run_node(script)
+
+ assert "PRIVATE-BEFORE" not in output["local"]
+ assert "PRIVATE-AFTER" not in output["local"]
+ assert output["metadata"] == [
+ {"filename": "before.jpg", "contentType": "image/jpeg", "stored": True},
+ {"filename": "after.jpg", "contentType": "image/jpeg", "stored": True},
+ ]
+ assert output["durable"] == [
+ {"filename": "before.jpg", "isBlob": True, "text": "PRIVATE-BEFORE"},
+ {"filename": "after.jpg", "isBlob": True, "text": "PRIVATE-AFTER"},
+ ]
diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py
index 34eddec..cce76f5 100644
--- a/tests/test_background_issue_sync.py
+++ b/tests/test_background_issue_sync.py
@@ -445,6 +445,56 @@ const fetchJson=async(url,options={{}})=>{{
assert output["second"]["confirmed"] == [{"id": 91}]
+def test_conversation_photo_bundle_resumes_at_first_unconfirmed_upload_and_posts_one_ordered_comment():
+ script = f"""
+const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
+let item={{id:'message-bundle',operationId:'message-bundle',ownerLogin:'timmy',status:'queued',
+ kind:'issue-comment',repository:'stackchain/dashboard',number:943,body:'Before and after',
+ attachments:[
+ {{filename:'before.jpg',contentType:'image/jpeg',blob:new Blob(['before'],{{type:'image/jpeg'}})}},
+ {{filename:'after.jpg',contentType:'image/jpeg',blob:new Blob(['after'],{{type:'image/jpeg'}})}},
+ ]}};
+const calls=[];let afterAttempts=0;
+const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
+ complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
+ fail:async()=>{{}},countBlocked:async()=>0}};
+const fetchJson=async(url,options={{}})=>{{
+ if(url==='api/v1/background-identity')return{{login:'timmy'}};
+ const file=options.body instanceof FormData?options.body.get('file'):null;
+ calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
+ body:options.body instanceof FormData?null:JSON.parse(options.body)}});
+ if(file){{
+ if(file.name==='after.jpg' && afterAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
+ return{{markdown:''}};
+ }}
+ return{{id:943}};
+}};
+(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
+ try{{await sync.flush();}}catch(error){{firstError=error.message;}}
+ const checkpoint={{...item}};const second=await sync.flush();
+ process.stdout.write(JSON.stringify({{calls,firstError,checkpoint,second}}));
+}})();
+"""
+ output = run_node(script)
+
+ assert output["firstError"] == "offline"
+ assert output["checkpoint"]["attachmentMarkdowns"] == [
+ ""
+ ]
+ uploads = [call for call in output["calls"] if call["filename"]]
+ assert [(call["filename"], call["key"]) for call in uploads] == [
+ ("before.jpg", "message-bundle:attachment-0"),
+ ("after.jpg", "message-bundle:attachment-1"),
+ ("after.jpg", "message-bundle:attachment-1"),
+ ]
+ comments = [call for call in output["calls"] if call["url"].endswith("/comments")]
+ assert len(comments) == 1
+ assert comments[0]["body"] == {
+ "body": "Before and after\n\n\n\n"
+ }
+ assert output["second"]["confirmed"] == [{"id": 943}]
+
+
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
@@ -865,6 +915,34 @@ const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...rec
assert output["attachmentMarkdown"] == ""
+def test_foreground_bundle_upsert_preserves_indexeddb_blobs_and_upload_checkpoints():
+ script = f"""
+const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
+const records=new Map();let tail=Promise.resolve();
+const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
+(async()=>{{
+ const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
+ await store.upsert({{id:'bundle',operationId:'same',ownerLogin:'timmy',status:'queued',attachments:[
+ {{filename:'a.jpg',contentType:'image/jpeg',blob:new Blob(['a-bytes'])}},
+ {{filename:'b.jpg',contentType:'image/jpeg',blob:new Blob(['b-bytes'])}},
+ ]}});
+ await store.update('bundle',item=>({{...item,attachmentMarkdowns:['']}}));
+ const result=await store.upsert({{id:'bundle',operationId:'same',ownerLogin:'timmy',status:'queued',attachments:[
+ {{filename:'a.jpg',contentType:'image/jpeg',stored:true}},
+ {{filename:'b.jpg',contentType:'image/jpeg',stored:true}},
+ ]}});
+ process.stdout.write(JSON.stringify({{texts:await Promise.all(result.attachments.map(value=>value.blob?.text())),
+ markdowns:result.attachmentMarkdowns}}));
+}})();
+"""
+ output = run_node(script)
+
+ assert output == {
+ "texts": ["a-bytes", "b-bytes"],
+ "markdowns": [""],
+ }
+
+
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 9c760e1..c06fc70 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v110" in worker
+ assert "stackchain-dashboard-shell-v111" in worker
diff --git a/tests/test_issue_attachment_ui.py b/tests/test_issue_attachment_ui.py
index 2d34dd2..3904d21 100644
--- a/tests/test_issue_attachment_ui.py
+++ b/tests/test_issue_attachment_ui.py
@@ -89,6 +89,30 @@ controller.select(image('replacement.png'));
assert len({key for _, key in output["calls"]}) == 5
+def test_mobile_conversation_composers_accept_five_ordered_photos():
+ html = INDEX.read_text()
+ dashboard = DASHBOARD.read_text()
+
+ for input_id in (
+ "issue-attachment", "pull-attachment", "update-reply-attachment",
+ ):
+ tag = re.search(rf'
]+id="{input_id}"[^>]*>', html)
+ assert tag, input_id
+ assert " multiple" in tag.group(0), input_id
+
+ for controller in (
+ "issueAttachmentController", "pullAttachmentController",
+ "updateReplyAttachmentController",
+ ):
+ mount = re.search(
+ rf"const {controller} = issueAttachment\.mount\(\{{(.*?)\n \}}\);",
+ dashboard,
+ re.DOTALL,
+ )
+ assert mount, controller
+ assert "maxFiles: 5" in mount.group(1), controller
+
+
def test_mobile_evidence_bundle_reorders_selected_image_for_serialization_and_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 3bfe664..5713b89 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 24a3e04..1a36e84 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v110" in worker
+ assert "stackchain-dashboard-shell-v111" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 5a3cc0b..87c4a6e 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v110" in worker
+ assert "stackchain-dashboard-shell-v111" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py
index c44c519..b59b622 100644
--- a/tests/test_mobile_device_setup.py
+++ b/tests/test_mobile_device_setup.py
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
- assert "stackchain-dashboard-shell-v110" in worker
+ assert "stackchain-dashboard-shell-v111" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py
index 45f6538..3b1cb22 100644
--- a/tests/test_mobile_start_day.py
+++ b/tests/test_mobile_start_day.py
@@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
- assert "stackchain-dashboard-shell-v110" in service_worker
+ assert "stackchain-dashboard-shell-v111" in service_worker
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index e063bec..557d056 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index f759a59..fe93a54 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -155,23 +155,32 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
+def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
+ source = WORKER.read_text()
+
+ assert "stackchain-dashboard-shell-v111" in source
+ assert "BASE + 'static/dashboard.js'" in source
+ assert "BASE + 'static/authored-outbox.js'" in source
+ assert "BASE + 'static/background-issue-sync.js'" in source
+
+
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -179,7 +188,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -187,7 +196,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -197,14 +206,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -213,21 +222,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -893,7 +902,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index 5750d2e..97da3f2 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
- assert "const CACHE = 'stackchain-dashboard-shell-v110';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v111';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index 1bc21cd..99a19f8 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
- assert "stackchain-dashboard-shell-v110" in source
+ assert "stackchain-dashboard-shell-v111" in source
assert "BASE + 'static/today-sync.js'" in source