diff --git a/README.md b/README.md
index 65a5707..3d0d439 100644
--- a/README.md
+++ b/README.md
@@ -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;
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 057d23c..20ce8b1 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -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;
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 3b2df09..2a85b4a 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 07877b6..5aedcb9 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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();
diff --git a/frontend/index.html b/frontend/index.html
index f097a01..ab44127 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -418,6 +418,19 @@
+
+ Screenshot Optional · PNG, JPEG, or WebP · 2 MB max
+
+
+
+
+
+
![Selected screenshot preview]()
+
+
+
+
+