From 48e4344f2824a1bcc8013676a3deef02a28de685 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 03:33:19 +0000 Subject: [PATCH] feat: preserve capture-first screenshots in Drafts (Closes #615) --- README.md | 17 +++++---- frontend/background-issue-sync.js | 15 ++++++++ frontend/dashboard.js | 41 +++++++++++++++------- frontend/index.html | 24 ++++++------- frontend/unfiled-captures.js | 57 ++++++++++++++++++++++++++----- src/frontend_bundle.py | 2 +- tests/test_unfiled_captures.py | 57 ++++++++++++++++++++++++++++++- 7 files changed, 171 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b5ad7f1..7b2d7b6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index e63d801..0dd51de 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -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; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c190255..51b6813 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -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(); diff --git a/frontend/index.html b/frontend/index.html index 5d0edfb..67dc26c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -536,18 +536,6 @@ -