feat: attach screenshots during issue capture (#469)
All checks were successful
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-10 09:35:09 +00:00
parent 11341ec5d9
commit 5bf491c725
19 changed files with 488 additions and 51 deletions

View File

@ -21,7 +21,11 @@ inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB.
The screenshot uploads before the comment is posted; validation or upload failures keep
both the typed comment and removable preview available for retry.
both the typed comment and removable preview available for retry. The mobile **New issue**
sheet accepts the same image formats and stores the screenshot with its account-bound
outbox capture. Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;

View File

@ -141,8 +141,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
const next = current && current.operationId === item.operationId ? {
...item,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
} : { ...item };
await records.put(next);
return next;
});
}
@ -177,6 +182,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
return {
reconcile,
upsert,
update,
claim,
claimNext,
claimBatch,
@ -343,10 +349,63 @@ function createBackgroundIssueSync({
};
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function deliverIssueCapture(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let deliveredIssue = item.deliveredIssue;
if (!deliveredIssue) {
const request = deliveryRequest(item);
deliveredIssue = await requestJson(request.url, request.options);
await store.update?.(item.id, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestJson(
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await store.update?.(item.id, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
}
await requestJson(
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: attachmentMarkdown }),
},
);
return deliveredIssue;
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await requestJson(request.url, request.options);
const delivered = item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;

View File

@ -337,10 +337,11 @@ textarea { resize: vertical; min-height: 120px; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }

View File

@ -303,6 +303,25 @@
);
},
});
const createIssueAttachmentController = issueAttachment.mount({
input: qs('#create-issue-attachment'),
preview: qs('#create-issue-attachment-preview'),
image: qs('#create-issue-attachment-image'),
meta: qs('#create-issue-attachment-meta'),
remove: qs('#remove-create-issue-attachment'),
status: qs('#create-issue-attachment-status'),
readyMessage: 'Screenshot ready to file with this issue.',
removedMessage: 'Screenshot removed. Your issue draft is unchanged.',
createObjectURL: file => URL.createObjectURL(file),
revokeObjectURL: url => URL.revokeObjectURL(url),
readDataUrl: file => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('The screenshot could not be read. Choose it again.'));
reader.readAsDataURL(file);
}),
upload: async () => { throw new Error('Create the issue before uploading its screenshot.'); },
});
const planningLoader = createIssueSheet.createPlanningLoader({
loadLabels: item => issueController.loadLabels(item),
loadMilestones: item => issueController.loadMilestones(item),
@ -1694,6 +1713,8 @@
editingOutboxId = queued.id;
issueCapture.saveDraft(queued);
openCreateIssueSheet();
if (queued.attachment) createIssueAttachmentController.restore(queued.attachment);
else createIssueAttachmentController.clear();
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
});
});
@ -3542,7 +3563,11 @@
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const durableDraft = createAndStartRequested ? { ...captureDraft, completionIntent: 'create-and-start' } : captureDraft;
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft);
const queued = admission.item;
@ -3556,6 +3581,7 @@
}
editingOutboxId = null;
issueCapture.clearDraft();
createIssueAttachmentController.clear();
suppressCreateDraftOnHistoryClose = true;
taskOverlayHistory.leave();
refreshMyWorkView();

View File

@ -418,6 +418,19 @@
<label for="create-issue-body">Description <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<button id="remove-create-issue-attachment" type="button">Remove</button>
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>

View File

@ -13,6 +13,7 @@
const upload = options.upload;
let selected = null;
let confirmed = null;
let serialized = null;
function select(file) {
if (!file || !IMAGE_TYPES.has(file.type)) {
@ -23,12 +24,25 @@
}
selected = file;
confirmed = null;
serialized = null;
return state();
}
function clear() {
selected = null;
confirmed = null;
serialized = null;
}
function restore(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const data = String(value?.data || '');
const padding = (data.match(/=*$/) || [''])[0].length;
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size });
serialized = { filename, contentType, data };
return state();
}
function state() {
@ -39,20 +53,33 @@
} : null;
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
async function serialize() {
if (!selected) return null;
if (!serialized) {
const dataUrl = await readDataUrl(selected);
const marker = ';base64,';
const markerAt = String(dataUrl).indexOf(marker);
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
serialized = {
filename: selected.name,
contentType: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
};
}
return { ...serialized };
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: selected.name,
content_type: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
filename: attachment.filename,
content_type: attachment.contentType,
data: attachment.data,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
@ -62,7 +89,7 @@
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, clear, state, prepareComment };
return { select, restore, clear, state, serialize, prepareComment };
}
function mount(options) {
@ -93,14 +120,25 @@
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = 'Screenshot ready to upload with this comment.';
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = 'Screenshot removed. Your comment is unchanged.';
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
});
return Object.assign(controller, { clear: clearPreview });
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
return restored;
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
}
return { create, mount, MAX_BYTES };

View File

@ -5,6 +5,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
);
const pending = new Map();
function captureAttachment(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '').slice(0, 255);
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType) || !data) return undefined;
return { filename, contentType, data };
}
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@ -40,6 +48,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
queuedAt: Number(now()),
};
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@ -76,14 +86,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
@ -91,6 +104,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
else delete updated.completionIntent;
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
delete updated.error;
delete updated.deliveryState;
return updated;
@ -119,6 +134,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return true;
}
function persistDeliveryStage(id, stage) {
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function sendDirect(item, repository) {
let issue = item.deliveredIssue;
if (!issue) {
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' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
},
);
markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
}
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: markdown }),
},
);
return issue;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
@ -135,18 +216,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
issue = delivery.issue;
} else {
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' } : {}),
}),
});
issue = await sendDirect(item, repository);
}
if (!issue) return { blocked: true };
discard(item.id);
@ -237,6 +307,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
const deliveryStage = {
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
};
if (background?.status === 'sent') {
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
@ -244,10 +318,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return [];
}
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...item, ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
return [{ ...item, ...deliveryStage }];
});
write(items);
return items;

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v80';
const CACHE = 'stackchain-dashboard-shell-v81';
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;

View File

@ -64,6 +64,71 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
}};
const state = {{calls:[],completed:0,released:0}};
const store = {{
claimNext:async()=>item ? {{...item}} : null,
update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{state.completed+=1;item=null;}},
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0,
}};
let uploadAttempts=0;
const fetchJson=async(url,options={{}})=>{{
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
if(url==='api/v1/background-identity')return{{login:'timmy'}};
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
if(url.endsWith('/attachments'))return{{markdown:'![phone.png](<https://forge.example/a/phone.png>)'}};
if(url.endsWith('/comments'))return{{id:91}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const afterFirst={{...item}};
const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Upload unavailable"
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
assert [call["url"] for call in output["state"]["calls"]].count(
"api/v1/repos/stackchain/dashboard/issues"
) == 1
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
assert output["state"]["calls"][-1]["body"] == {
"body": "![phone.png](<https://forge.example/a/phone.png>)"
}
assert output["state"]["completed"] == 1
assert output["second"]["confirmed"][0]["number"] == 469
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
const keys=[];let queued=true;
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:'![a](url)'}};return{{id:1}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
"""
keys = run_node(script)
assert len(keys) == 3
assert len(set(keys)) == 3
assert all(len(key) <= 128 for key in keys)
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@ -289,6 +354,25 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
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:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:'![a](url)'}}));
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read()
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-v80" in worker
assert "stackchain-dashboard-shell-v81" in worker

View File

@ -54,6 +54,43 @@ controller.select(file);
}
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls=[];
const controller=attachment.create({{
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
upload:async()=>{{calls.push('upload');}},
}});
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
"""
output = json.loads(run_node(script))
assert output == {
"value": {
"filename": "phone.webp",
"contentType": "image/webp",
"data": "UklGRg==",
},
"calls": ["read:phone.webp"],
}
def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
"""
output = json.loads(run_node(script))
assert output["restored"]["state"]["name"] == "saved.png"
assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
assert output["removed"] is None
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
html = INDEX.read_text()
css = CSS.read_text()
@ -69,6 +106,23 @@ def test_issue_composer_renders_thumb_reachable_screenshot_preview():
assert 'min-height:44px' in css
def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source
assert "createIssueAttachmentController.restore(queued.attachment);" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
@ -132,3 +186,5 @@ def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
assert "PNG, JPEG, or WebP" in readme
assert "2 MB" in readme
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme

View File

@ -71,6 +71,29 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
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)}};
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'capture-image-1',
}});
outbox.enqueue({{
repository:'stackchain/dashboard',title:'Layout breaks',body:'At 320px',
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'UklGRg=='}},
}});
process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
"""
output = run_node(script)
assert output["attachment"] == {
"filename": "phone.webp",
"contentType": "image/webp",
"data": "UklGRg==",
}
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@ -154,6 +177,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
assert output["remaining"][0]["status"] == "queued"
def test_foreground_attachment_retry_does_not_create_a_second_issue():
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)}};
const calls=[];let uploadAttempts=0;
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
fetchJson:async(url,options={{}})=>{{
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
if(url.endsWith('/attachments'))return{{markdown:'![screen.png](<https://forge/a.png>)'}};
return{{id:8}};
}},
}});
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
"""
output = run_node(script)
assert output["partial"]["deliveredIssue"]["number"] == 7
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
assert output["result"]["confirmed"][0]["number"] == 7
assert output["remaining"] == []
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
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)}};let sequence=0;
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-'+(++sequence)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'old.png',contentType:'image/png',data:'old'}}}});
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{...queued,deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:'![old](url)'}}]}}));
const updated=outbox.update(queued.id,{{...queued,attachment:{{filename:'new.png',contentType:'image/png',data:'new'}}}});
process.stdout.write(JSON.stringify(updated));
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachment"]["filename"] == "new.png"
assert "attachmentMarkdown" not in output
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@ -434,6 +501,21 @@ process.stdout.write(JSON.stringify(outbox.list()));
]
def test_page_reconciliation_keeps_confirmed_attachment_delivery_stages():
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)}};
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'staged'}});
outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});
outbox.reconcileBackground([{{id:'staged',status:'queued',deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:'![a](url)'}}]);
process.stdout.write(JSON.stringify(outbox.list()[0]));
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,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-v80" in worker
assert "stackchain-dashboard-shell-v81" in worker

View File

@ -35,7 +35,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-v80" in worker
assert "stackchain-dashboard-shell-v81" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -292,6 +292,6 @@ 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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source

View File

@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" 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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -146,7 +146,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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v80" in source
assert "stackchain-dashboard-shell-v81" 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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@ -170,21 +170,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-v80" in source
assert "stackchain-dashboard-shell-v81" 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-v80" in source
assert "stackchain-dashboard-shell-v81" 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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -385,7 +385,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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/queue-today.js'" in source

View File

@ -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-v80';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -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-v80" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-sync.js'" in source