diff --git a/README.md b/README.md index dcb1700..f268f7e 100644 --- a/README.md +++ b/README.md @@ -19,8 +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 comments can include one PNG, JPEG, or WebP screenshot; the 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; validation or upload +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 diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index b48c27f..68f1e28 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -75,7 +75,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, status: 'queued', queuedAt: Number(now()), ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), - ...(message.kind === 'issue-comment' && message.attachment ? { + ...(['issue-comment', 'pull-comment'].includes(message.kind) && message.attachment ? { attachment: { filename: String(message.attachment.filename || ''), contentType: String(message.attachment.contentType || ''), diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 1f552fb..24c2213 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -510,11 +510,12 @@ 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 + '/issues/' + encodeURIComponent(item.number) + '/attachments', + base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments', { method: 'POST', headers: { @@ -535,7 +536,7 @@ function createBackgroundIssueSync({ const text = String(item.body || '').trim(); return requestStage( item, - base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/comments', + base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments', { method: 'POST', headers: { @@ -551,7 +552,7 @@ function createBackgroundIssueSync({ const request = deliveryRequest(item); try { const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) : - item.attachment && item.kind === 'issue-comment' ? + item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ? await deliverScreenshotComment(item) : item.attachment && !item.kind ? await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); if (item.kind === 'issue-close' && delivered?.state !== 'closed') { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a5a6f94..c5d1ab4 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -303,6 +303,27 @@ ); }, }); + const pullAttachmentController = issueAttachment.mount({ + input: qs('#pull-attachment'), + preview: qs('#pull-attachment-preview'), + image: qs('#pull-attachment-image'), + meta: qs('#pull-attachment-meta'), + remove: qs('#remove-pull-attachment'), + status: qs('#pull-comment-status'), + createObjectURL: file => URL.createObjectURL(file), + revokeObjectURL: url => URL.revokeObjectURL(url), + upload: payload => { + const repository = payload.repository.split('/').map(encodeURIComponent).join('/'); + return fetchReviewJson( + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(payload.number) + '/attachments', + { + method: 'POST', + headers: { Accept:'application/json', 'Idempotency-Key':payload.operation_id }, + body: issueAttachment.multipart(payload), + }, + ); + }, + }); const createIssueAttachmentController = issueAttachment.mount({ input: qs('#create-issue-attachment'), preview: qs('#create-issue-attachment-preview'), @@ -2343,8 +2364,14 @@ } } + function sameWorkTarget(left, right) { + return Boolean(left && right && left.repository === right.repository && + Number(left.number) === Number(right.number)); + } + async function openPullSheet(item, trigger, offlineDetail = null) { if (!item) return; + if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear(); qs('#pull-review').inert = false; selectedPull = item; pullMentions.dismiss(); @@ -2407,6 +2434,7 @@ return; } mobileComposerViewport.close(qs('#pull-sheet .pull-sheet-panel')); + pullAttachmentController.clear(); qs('#pull-sheet').classList.remove('open'); selectedPull = null; selectedPullDetail = null; @@ -3929,13 +3957,32 @@ return admission; } + async function queuePullScreenshotComment(item, body, operationId, advance = false, deliver = false) { + const message = { + kind: 'pull-comment', repository: item.repository, number: item.number, body, + operationId: operationId || globalThis.crypto?.randomUUID?.() || String(Date.now()), + attachment: await pullAttachmentController.serialize(), + }; + if (advance) return await pullCommentNext.admit(item, message); + const admission = await authoredOutbox.enqueueDurably(message); + if (deliver) { + const delivery = await authoredOutbox.retry(admission.item.id, activeFlushLogin); + return { ...admission, delivered: delivery.confirmed?.[0] || null }; + } + pullController.saveDraft(item, ''); + if (selectedPull === item) qs('#pull-comment').value = ''; + return admission; + } + async function submitCommentAndNext(kind) { const item = kind === 'issue' ? selectedIssue : selectedPull; if (!item || !workSession.checkpointed(item)) return; const textarea = qs('#' + kind + '-comment'); const status = qs('#' + kind + '-comment-status'); const body = textarea.value.trim(); - if (!body && (kind !== 'issue' || !issueAttachmentController.state())) { + const attachmentController = kind === 'issue' ? issueAttachmentController : pullAttachmentController; + const queueScreenshot = kind === 'issue' ? queueIssueScreenshotComment : queuePullScreenshotComment; + if (!body && !attachmentController.state()) { status.textContent = 'Write a comment before posting.'; textarea.focus(); return; @@ -3947,26 +3994,28 @@ item.repository + '#' + item.number + ':operation'); postButton.disabled = true; nextButton.disabled = true; - status.textContent = kind === 'issue' && issueAttachmentController.state() ? + attachmentController.setBusy(true); + status.textContent = attachmentController.state() ? 'Uploading screenshot before opening next…' : 'Posting comment and opening next…'; try { let result; - if (kind === 'issue' && issueAttachmentController.state() && navigator.onLine === false) { - result = await queueIssueScreenshotComment(item, body, operationId(), true); + if (attachmentController.state() && (kind === 'pull' || navigator.onLine === false)) { + result = await queueScreenshot(item, body, operationId(), true); } else { let preparedBody; try { preparedBody = kind === 'issue' ? - await issueAttachmentController.prepareComment(item, body) : body; + await issueAttachmentController.prepareComment(item, body) : + await pullAttachmentController.prepareComment(item, body); } catch (error) { - if (kind !== 'issue' || !issueAttachmentController.state() || !canQueueMessage(error)) throw error; - result = await queueIssueScreenshotComment(item, body, operationId(), true); + if (!attachmentController.state() || !canQueueMessage(error)) throw error; + result = await queueScreenshot(item, body, operationId(), true); } if (!result) result = await controller.submit(item, preparedBody, operationId); } const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item; if (!stillOpen) return; - if (kind === 'issue') issueAttachmentController.clear(); + attachmentController.clear(); if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.'; else if (result.delivery === 'posted') status.textContent = 'Comment posted.'; else if (result.background) status.textContent = 'Queued for sync when the connection returns.'; @@ -3975,6 +4024,7 @@ status.textContent = error.message + ' Your draft, screenshot, and Today position are safe; retry.'; textarea.focus(); } finally { + attachmentController.setBusy(false); postButton.disabled = false; nextButton.disabled = false; } @@ -4234,39 +4284,68 @@ }); qs('#send-pull-comment').addEventListener('click', async () => { if (!selectedPull) return; + const item = selectedPull; const body = qs('#pull-comment').value.trim(); - if (!body) { + if (!body && !pullAttachmentController.state()) { qs('#pull-comment-status').textContent = 'Write a comment before posting.'; qs('#pull-comment').focus(); return; } const button = qs('#send-pull-comment'); button.disabled = true; - qs('#pull-comment-status').textContent = 'Posting comment…'; + pullAttachmentController.setBusy(true); + qs('#pull-comment-status').textContent = pullAttachmentController.state() ? + 'Uploading screenshot…' : 'Posting comment…'; + const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + + item.repository + '#' + item.number + ':operation'); + let preparedBody; try { - const comment = await pullController.comment(selectedPull, body); - if (pullConversation) renderPullConversation(pullConversation.append(comment)); - qs('#pull-comment').value = ''; + if (pullAttachmentController.state()) { + const admission = await queuePullScreenshotComment(item, body, operationId, false, true); + refreshMyWorkView(); + pullController.saveDraft(item, ''); + if (selectedPull === item) { + if (admission.delivered && pullConversation) { + renderPullConversation(pullConversation.append(admission.delivered)); + } + qs('#pull-comment').value = ''; + pullAttachmentController.clear(); + qs('#pull-comment-status').textContent = admission.delivered ? 'Comment posted.' : + (admission.background ? 'Queued with screenshot for sync when the connection returns.' : + 'Saved with screenshot for next launch; background delivery unavailable.'); + } + return; + } + preparedBody = body; + const comment = await pullController.comment(item, preparedBody); + if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment)); + pullController.saveDraft(item, ''); + if (selectedPull === item) qs('#pull-comment').value = ''; + pullAttachmentController.clear(); qs('#pull-comment-status').textContent = 'Comment posted.'; } catch (error) { - if (canQueueMessage(error)) { - const operationId = localStorage.getItem('stackchain.pull-comment.v1:' + selectedPull.repository + '#' + selectedPull.number + ':operation'); - qs('#pull-comment-status').textContent = 'Saving for background delivery…'; - const admission = await authoredOutbox.enqueueDurably({ kind:'pull-comment', repository:selectedPull.repository, - number:selectedPull.number, body, operationId }); - refreshMyWorkView(); - if (admission.background) { - qs('#pull-comment').value = ''; - qs('#pull-comment-status').textContent = 'Queued for sync when the connection returns.'; - } else { - qs('#pull-comment-status').textContent = 'Saved for next launch; background delivery unavailable.'; - qs('#pull-comment').focus(); + if (canQueueMessage(error) && !pullAttachmentController.state()) { + try { + qs('#pull-comment-status').textContent = 'Saving for background delivery…'; + const admission = await authoredOutbox.enqueueDurably({ + kind:'pull-comment', repository:item.repository, number:item.number, + body:preparedBody ?? body, operationId, + }); + pullController.saveDraft(item, ''); + if (selectedPull === item) qs('#pull-comment').value = ''; + refreshMyWorkView(); + qs('#pull-comment-status').textContent = admission.background ? + 'Queued for sync when the connection returns.' : + 'Saved for next launch; background delivery unavailable.'; + return; + } catch (admissionError) { + error = admissionError; } - } else { - qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.'; - qs('#pull-comment').focus(); } + qs('#pull-comment-status').textContent = error.message + ' Your comment and screenshot are safe; retry.'; + qs('#pull-comment').focus(); } finally { + pullAttachmentController.setBusy(false); button.disabled = false; } }); diff --git a/frontend/index.html b/frontend/index.html index c40e687..4163646 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -558,6 +558,15 @@
+ +