Merge pull request 'Preserve screenshots in capture-first mobile Drafts' (#616) from timmy/615-capture-first-screenshots into main
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
rockachopa 2026-08-12 03:35:30 +00:00
commit f43f776479
7 changed files with 171 additions and 42 deletions

View File

@ -24,13 +24,16 @@ For online delivery, the screenshot uploads before the comment is posted—to th
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**
sheet accepts the same image formats and stores the screenshot with its account-bound
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
queued base64 screenshot payloads remain readable and are converted only at delivery time.
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
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.

View File

@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
return transact;
}
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
return {
put: (id, value) => transact(records => records.put({id, ...value})),
get: id => transact(async records => {
const value = await records.get(id);
if (!value) return null;
const {id: _id, ...attachment} = value;
return attachment;
}),
delete: id => transact(records => records.delete(id)),
};
}
function createIssueSyncStore({
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
createToken = () => globalThis.crypto?.randomUUID?.() ||
@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
}

View File

@ -400,11 +400,14 @@
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
attachmentStore: unfiledAttachmentStore,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
let resumedUnfiledCaptureId = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
@ -1940,7 +1943,8 @@
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
@ -2107,15 +2111,16 @@
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
issueCapture.saveDraft(resumed);
refreshMyWorkView();
openCreateIssueSheet();
resumedUnfiledCaptureId = item.capture_id;
await openCreateIssueSheet();
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
@ -2191,10 +2196,10 @@
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
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) draftInbox.discard(item.id);
@ -4057,16 +4062,22 @@
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-repository-search').focus();
});
qs('#save-unfiled-issue').addEventListener('click', () => {
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
};
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
unfiledCaptures.save(captureDraft);
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
};
await unfiledCaptures.save(captureDraft);
if (resumedUnfiledCaptureId) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
@ -4200,6 +4211,10 @@
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft);
const queued = admission.item;
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
}
if (!admission.background) {
editingOutboxId = queued.id;
refreshMyWorkView();

View File

@ -536,18 +536,6 @@
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<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">
@ -561,6 +549,18 @@
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<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

@ -1,5 +1,6 @@
function createUnfiledCaptures({
storage,
attachmentStore = null,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
@ -35,10 +36,39 @@ function createUnfiledCaptures({
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
write(items);
return item;
const attachment = note?.attachment;
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (hasAttachment && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
const item = {
id:String(createId()), ownerLogin, title, body, savedAt:Number(now()),
...(hasAttachment ? {hasAttachment:true} : {}),
};
const existing = read().filter(candidate => candidate.id !== item.id);
const items = [item, ...existing].slice(0, maxItems);
const pruned = existing.filter(candidate => !items.some(retained => retained.id === candidate.id));
if (!hasAttachment) {
write(items);
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
Promise.resolve(attachmentStore?.delete(candidate.id)).catch(() => {}));
return item;
}
return Promise.resolve(attachmentStore.put(item.id, {
filename:String(attachment.filename).slice(0, 255),
contentType:String(attachment.contentType), blob:attachment.blob,
})).then(() => {
try { write(items); }
catch (error) {
Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
Promise.resolve(attachmentStore.delete(candidate.id)).catch(() => {}));
return item;
});
}
function discard(id) {
@ -46,7 +76,9 @@ function createUnfiledCaptures({
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
return true;
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) return true;
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
}
function resume(id, confirmedLogin) {
@ -55,11 +87,20 @@ function createUnfiledCaptures({
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
discard(id);
return {repository:'', title:item.title, body:item.body, labelIds:[]};
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
return {...draft, attachment};
});
}
return {list, save, discard, resume};
function completeResume(id) { return discard(id); }
return {list, save, discard, resume, completeResume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;

View File

@ -29,7 +29,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js",
"static/today-rollover.js", "static/unfiled-captures.js",
),
}
CACHE_DECLARATION = re.compile(

View File

@ -65,6 +65,7 @@ const second = captures.save({{title:'Second',body:'Two'}});
let mismatch = '';
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
const resumed = captures.resume(first.id, 'timmy');
captures.completeResume(first.id);
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
"""
output = run_node(script)
@ -74,6 +75,52 @@ process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list()
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => blobs.delete(id),
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
const listed = captures.list();
const resumed = await captures.resume(saved.id, 'timmy');
const beforeComplete = captures.list().length;
await captures.completeResume(saved.id);
process.stdout.write(JSON.stringify({{
listed, beforeComplete, afterComplete:captures.list().length,
resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
blobRemoved:!blobs.has(saved.id),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output["listed"][0]["hasAttachment"] is True
assert "attachment" not in output["listed"][0]
assert output["beforeComplete"] == 1
assert output["afterComplete"] == 0
assert output["blobRemoved"] is True
assert output["resumed"] == {
"title": "Broken mobile layout",
"body": "At 320px",
"filename": "phone.png",
"contentType": "image/png",
"size": 5,
}
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@ -104,7 +151,12 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "createUnfiledCaptures({" in html
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
assert "unfiledCaptures.save(captureDraft)" in html
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "issueCapture.saveDraft(resumed)" in html
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
@ -125,3 +177,6 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f
assert "qs('#file-new-issue').addEventListener('click'" in html
assert '.create-issue-capture-actions button { min-height:44px;' in html
assert '.create-issue-capture-actions[hidden] { display:none;' in html
attachment = html.index('class="create-issue-attachment"')
filing = html.index('class="create-issue-filing"')
assert attachment < filing