diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js
index 68f1e28..05f7252 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 } : {}),
- ...(['issue-comment', 'pull-comment'].includes(message.kind) && message.attachment ? {
+ ...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
@@ -99,6 +99,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const item = enqueue(message, false);
+ if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
+ (!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
+ write(read().filter(candidate => candidate.id !== item.id), false);
+ throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
+ }
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js
index 24c2213..36e9c0a 100644
--- a/frontend/background-issue-sync.js
+++ b/frontend/background-issue-sync.js
@@ -437,10 +437,32 @@ function createBackgroundIssueSync({
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
+ let attachmentMarkdown = current.attachmentMarkdown;
+ if (current.attachment && !attachmentMarkdown) {
+ const uploaded = await requestStage(current,
+ base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
+ method:'POST',
+ headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
+ body:attachmentMultipart(current.attachment),
+ });
+ attachmentMarkdown = String(uploaded?.markdown || '');
+ if (!attachmentMarkdown) {
+ const error = new Error('The server did not confirm the screenshot upload.');
+ error.status = 422;
+ throw error;
+ }
+ await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
+ current = { ...current, attachmentMarkdown };
+ }
+ const text = String(current.body || '').trim();
+ const replyBody = attachmentMarkdown ?
+ (text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
+ const options = authoredRequest('', { ...current, body:replyBody }).options;
+ if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
- authoredRequest('', current).options,
+ options,
);
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
@@ -548,10 +570,40 @@ function createBackgroundIssueSync({
);
}
+ async function deliverUpdateScreenshotReply(item) {
+ let current = item;
+ let attachmentMarkdown = current.attachmentMarkdown;
+ if (!attachmentMarkdown) {
+ const uploaded = await requestStage(current,
+ base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
+ method:'POST',
+ headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
+ body:attachmentMultipart(current.attachment),
+ });
+ attachmentMarkdown = String(uploaded?.markdown || '');
+ if (!attachmentMarkdown) {
+ const error = new Error('The server did not confirm the screenshot upload.');
+ error.status = 422;
+ throw error;
+ }
+ await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
+ current = { ...current, attachmentMarkdown };
+ }
+ const text = String(current.body || '').trim();
+ return requestStage(current,
+ base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
+ method:'POST',
+ headers:{ Accept:'application/json', 'Content-Type':'application/json',
+ 'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
+ body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
+ });
+ }
+
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
+ item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
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);
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 428de66..d8cd931 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -255,6 +255,12 @@ textarea { resize: vertical; min-height: 120px; }
.update-reply button { min-height:44px; width:100%; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions button { min-height:44px; width:100%; }
+.update-reply .issue-attachment-preview { width:100%; min-width:0; }
+@media (max-width:390px) {
+ .update-reply-actions { grid-template-columns:1fr; }
+ .update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
+ .update-reply .issue-attachment-preview img { width:56px; height:56px; }
+}
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-ownership-actions button { min-width:0; width:100%; }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 5786cbe..ef21b86 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -324,6 +324,23 @@
);
},
});
+ const updateReplyAttachmentController = issueAttachment.mount({
+ input: qs('#update-reply-attachment'),
+ preview: qs('#update-reply-attachment-preview'),
+ image: qs('#update-reply-attachment-image'),
+ meta: qs('#update-reply-attachment-meta'),
+ remove: qs('#remove-update-reply-attachment'),
+ status: qs('#update-reply-status'),
+ readyMessage: 'Screenshot ready to send with this reply.',
+ removedMessage: 'Screenshot removed. Your reply is unchanged.',
+ createObjectURL: file => URL.createObjectURL(file),
+ revokeObjectURL: url => URL.revokeObjectURL(url),
+ upload: payload => fetchReviewJson(
+ 'api/v1/notifications/' + encodeURIComponent(payload.notificationId) + '/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'),
@@ -635,6 +652,9 @@
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
loadSaved: item => offlineWorkStore.loadDetail(confirmedOwnerLogin, item),
onOpen: item => {
+ if (selectedUpdate && selectedUpdate.notification_id !== item.notification_id) {
+ updateReplyAttachmentController.clear();
+ }
selectedUpdate = item;
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
@@ -1093,6 +1113,7 @@
post: (item, body, operationId) => postNotificationReply(item.notification_id, body, operationId),
markRead: markNotificationRead,
queue: message => authoredOutbox.enqueueDurably(message),
+ deliver: item => authoredOutbox.retry(item.id, activeFlushLogin),
canQueue: canQueueMessage,
accept: item => {
notificationReplier.saveDraft(item, '');
@@ -3080,6 +3101,7 @@
return;
}
mobileComposerViewport.close(qs('#update-sheet .update-sheet-panel'));
+ updateReplyAttachmentController.clear();
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
@@ -4448,21 +4470,26 @@
qs('#send-update-reply').addEventListener('click', async () => {
if (!selectedUpdate) return;
const body = qs('#update-reply').value.trim();
- if (!body) {
- qs('#update-reply-status').textContent = 'Write a reply before sending.';
+ const attachment = await updateReplyAttachmentController.serialize();
+ if (!body && !attachment) {
+ qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
qs('#send-update-reply').disabled = true;
- const result = await notificationReplier.submit(selectedUpdate, body);
+ updateReplyAttachmentController.setBusy(true);
+ const result = await notificationReplier.submit(selectedUpdate, body, attachment);
qs('#send-update-reply').disabled = false;
+ updateReplyAttachmentController.setBusy(false);
if (result?.queued) {
qs('#update-reply').value = '';
+ updateReplyAttachmentController.clear();
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Reply queued for sync.';
} else if (result) {
notificationReader.appendReply(result);
qs('#update-reply').value = '';
+ updateReplyAttachmentController.clear();
qs('#mark-update-read-next').focus();
} else {
qs('#update-reply').focus();
@@ -4472,8 +4499,9 @@
if (!selectedUpdate) return;
const item = selectedUpdate;
const body = qs('#update-reply').value.trim();
- if (!body) {
- qs('#update-reply-status').textContent = 'Write a reply before sending.';
+ const attachment = await updateReplyAttachmentController.serialize();
+ if (!body && !attachment) {
+ qs('#update-reply-status').textContent = 'Write a reply or attach a screenshot before sending.';
qs('#update-reply').focus();
return;
}
@@ -4481,10 +4509,12 @@
const sendButton = qs('#send-update-reply');
button.disabled = true;
sendButton.disabled = true;
+ updateReplyAttachmentController.setBusy(true);
qs('#update-reply-status').textContent = 'Replying, then marking read…';
const operationId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random();
try {
- const result = await updateReplyReadNext.submit(item, body, operationId);
+ const result = await updateReplyReadNext.submit(item, body, operationId, attachment);
+ if (result?.accepted) updateReplyAttachmentController.clear();
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
@@ -4494,6 +4524,7 @@
} finally {
button.disabled = false;
sendButton.disabled = false;
+ updateReplyAttachmentController.setBusy(false);
}
});
qs('#mark-update-read-next').addEventListener('click', async () => {
diff --git a/frontend/index.html b/frontend/index.html
index e8f14b5..321f547 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -516,6 +516,15 @@
+
+
+
+
+
+
![Selected screenshot preview]()
+
+
+
diff --git a/frontend/my-work.js b/frontend/my-work.js
index bed62c9..7cc00ec 100644
--- a/frontend/my-work.js
+++ b/frontend/my-work.js
@@ -404,7 +404,7 @@ function createNotificationReplier({
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
- async submit(item, body) {
+ async submit(item, body, attachment = null) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
@@ -415,6 +415,23 @@ function createNotificationReplier({
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
+ if (attachment) {
+ onStatus('Saving screenshot for durable delivery…');
+ const admission = await authoredOutbox.enqueueDurably({
+ kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
+ });
+ const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
+ if (!delivery.confirmed?.length) {
+ const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
+ if (remaining?.status === 'attention') return false;
+ onStatus('Queued for sync when the connection returns.');
+ return { queued:true };
+ }
+ try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
+ catch (_error) { /* Confirmed delivery is authoritative. */ }
+ onStatus('Reply posted. You can mark this update read when ready.');
+ return delivery.confirmed[0];
+ }
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 4b95338..8ce435a 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v86';
+const CACHE = 'stackchain-dashboard-shell-v87';
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;
diff --git a/frontend/update-reply-read-next.js b/frontend/update-reply-read-next.js
index 8bd5f82..6e182d8 100644
--- a/frontend/update-reply-read-next.js
+++ b/frontend/update-reply-read-next.js
@@ -1,5 +1,5 @@
function createUpdateReplyReadNext({
- post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
+ post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
}) {
let inFlight = null;
@@ -22,11 +22,25 @@ function createUpdateReplyReadNext({
};
}
- function submit(item, body, operationId) {
+ function submit(item, body, operationId, attachment = null) {
if (inFlight) return inFlight;
inFlight = (async () => {
let replyConfirmed = false;
try {
+ if (attachment) {
+ const admission = await queue({
+ kind:'update-reply-read', notificationId:item.notification_id, body,
+ operationId, replyConfirmed:false, attachment,
+ });
+ if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
+ const outcome = deliver ? await deliver(admission.item) : null;
+ const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
+ if (remaining?.status === 'attention') {
+ throw new Error(remaining.error || 'Reply and screenshot need attention.');
+ }
+ accept(item);
+ return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
+ }
try {
await post(item, body, operationId);
replyConfirmed = true;
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 5985236..2d10f4d 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -892,6 +892,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
return _normalize_issue_comment(comment)
+async def upload_notification_attachment(
+ thread_id: int, filename: str, content_type: str, content: bytes
+) -> dict:
+ """Upload to the exact issue or pull identified by a trusted notification."""
+ thread = await fetch(f"notifications/threads/{thread_id}")
+ if not isinstance(thread, dict):
+ raise ValueError("Gitea notification thread response was not an object")
+ repository = thread.get("repository")
+ subject = thread.get("subject")
+ if not isinstance(repository, dict) or not isinstance(subject, dict):
+ raise ValueError("Notification does not identify a conversation")
+ subject_path = _gitea_api_path(subject.get("url"))
+ match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
+ if (
+ not match
+ or match.group(1) != repository.get("full_name")
+ or subject.get("type") not in {"Issue", "Pull"}
+ ):
+ raise ValueError("Notification subject is not a supported conversation")
+ # Gitea stores pull-request assets on its shared issue asset endpoint.
+ response = await _get_client().post(
+ f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets",
+ headers=_auth(), params={"name": filename},
+ files={"attachment": (filename, content, content_type)},
+ )
+ response.raise_for_status()
+ attachment = response.json()
+ if not isinstance(attachment, dict):
+ raise ValueError("Gitea attachment response was not an object")
+ name = attachment.get("name")
+ url = _safe_web_url(attachment.get("browser_download_url"))
+ size = attachment.get("size")
+ if not isinstance(name, str) or not name or not url or not isinstance(size, int):
+ raise ValueError("Gitea did not confirm the attachment")
+ return {"name": name, "url": url, "size": size}
+
+
async def close_issue(repository: str, number: int) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
diff --git a/src/main.py b/src/main.py
index ccb2898..2388269 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2991,6 +2991,60 @@ async def reply_to_notification(
return JSONResponse(result, status_code=201)
+@app.post("/api/v1/notifications/{thread_id}/attachments", status_code=201)
+async def attach_to_notification(
+ request: Request,
+ thread_id: int = PathParam(gt=0),
+ idempotency_key: str | None = Header(default=None, max_length=128),
+) -> JSONResponse:
+ try:
+ if request.headers.get("content-type", "").lower().startswith("multipart/form-data"):
+ form = await request.form()
+ uploaded = form.get("file")
+ if not isinstance(uploaded, UploadFile):
+ raise ValueError("screenshot file is required")
+ filename = str(uploaded.filename or "")
+ content_type = str(uploaded.content_type or "")
+ content = _validate_binary_attachment(filename, content_type, await uploaded.read())
+ else:
+ attachment = IssueAttachment.model_validate(await request.json())
+ filename = attachment.filename
+ content_type = attachment.content_type
+ content = attachment.content()
+ except (ValueError, ValidationError) as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+ async def upload_attachment():
+ result = await gitea_proxy.upload_notification_attachment(
+ thread_id, filename, content_type, content
+ )
+ safe_name = (
+ result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
+ .replace("\r", " ").replace("\n", " ")
+ )
+ safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
+ result["markdown"] = f""
+ return result
+
+ try:
+ result = await _run_idempotent_authored_action(
+ upload_attachment(), idempotency_key=idempotency_key,
+ fingerprint=("notification-attachment", thread_id, filename, content_type,
+ hashlib.sha256(content).hexdigest()),
+ timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
+ )
+ except HTTPException:
+ raise
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ except Exception:
+ return JSONResponse(
+ {"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
+ status_code=503, headers={"Retry-After": "1"},
+ )
+ return JSONResponse(result, status_code=201)
+
+
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
async def claim_available_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
diff --git a/src/request_boundary.py b/src/request_boundary.py
index 20020b2..0914555 100644
--- a/src/request_boundary.py
+++ b/src/request_boundary.py
@@ -17,8 +17,10 @@ def request_body_limit(method: str, path: str) -> int | None:
return SESSION_BODY_LIMIT
if (
normalized_method == "POST"
- and path.startswith("/api/v1/repos/")
- and ("/issues/" in path or "/pulls/" in path)
+ and (
+ (path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
+ or path.startswith("/api/v1/notifications/")
+ )
and path.endswith("/attachments")
):
return ISSUE_ATTACHMENT_BODY_LIMIT
diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py
index 0702e0a..b0c345c 100644
--- a/tests/test_comment_next.py
+++ b/tests/test_comment_next.py
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
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-v86" in worker
+ assert "stackchain-dashboard-shell-v87" in worker
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index 77ac34f..7388c2f 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -89,7 +89,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
- "const CACHE = 'stackchain-dashboard-shell-v86';",
+ "const CACHE = 'stackchain-dashboard-shell-v87';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
index 258b9bf..e64f184 100644
--- a/tests/test_later_sync.py
+++ b/tests/test_later_sync.py
@@ -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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index 2c62d09..4a59c7f 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -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-v86" in worker
+ assert "stackchain-dashboard-shell-v87" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index e149b73..f5ae168 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -41,7 +41,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-v86" in worker
+ assert "stackchain-dashboard-shell-v87" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py
index d70af78..5ea4903 100644
--- a/tests/test_plan_today.py
+++ b/tests/test_plan_today.py
@@ -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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 31ca15d..1307134 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -125,7 +125,7 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -134,14 +134,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-v86" in source
+ assert "stackchain-dashboard-shell-v87" 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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -149,7 +149,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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -157,14 +157,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-v86" in source
+ assert "stackchain-dashboard-shell-v87" 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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -173,21 +173,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-v86" in source
+ assert "stackchain-dashboard-shell-v87" 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-v86" in source
+ assert "stackchain-dashboard-shell-v87" 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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -397,7 +397,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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/queue-today.js'" in source
diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py
index aa9f00d..d041f95 100644
--- a/tests/test_today_readiness.py
+++ b/tests/test_today_readiness.py
@@ -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-v86';" in service_worker
+ assert "const CACHE = 'stackchain-dashboard-shell-v87';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
index cec781c..6b2d48a 100644
--- a/tests/test_today_sync.py
+++ b/tests/test_today_sync.py
@@ -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-v86" in source
+ assert "stackchain-dashboard-shell-v87" in source
assert "BASE + 'static/today-sync.js'" in source
diff --git a/tests/test_update_reply_attachments.py b/tests/test_update_reply_attachments.py
new file mode 100644
index 0000000..1542bac
--- /dev/null
+++ b/tests/test_update_reply_attachments.py
@@ -0,0 +1,178 @@
+import json
+import subprocess
+from pathlib import Path
+
+import httpx
+import pytest
+
+from src import gitea_proxy, main
+from tests.dashboard_bundle import dashboard
+
+
+ROOT = Path(__file__).parents[1]
+OUTBOX = ROOT / "frontend" / "authored-outbox.js"
+SYNC = ROOT / "frontend" / "background-issue-sync.js"
+PNG = b"\x89PNG\r\n\x1a\nmobile-update"
+
+
+def run_node(script: str):
+ return json.loads(subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout)
+
+
+@pytest.fixture(autouse=True)
+def clear_idempotency():
+ main._authored_action_operations.clear()
+ main._idempotency_ledger.clear()
+ yield
+ main._authored_action_operations.clear()
+ main._idempotency_ledger.clear()
+
+
+@pytest.mark.anyio
+async def test_notification_attachment_endpoint_resolves_exact_pull_server_side(monkeypatch):
+ calls = []
+
+ async def upload(thread_id, filename, content_type, content):
+ calls.append((thread_id, filename, content_type, content))
+ return {"name": filename, "url": "https://forge.example/a/proof.webp", "size": len(content)}
+
+ monkeypatch.setattr(main.gitea_proxy, "upload_notification_attachment", upload, raising=False)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.post(
+ "/api/v1/notifications/527/attachments",
+ files={"file": ("proof.png", PNG, "image/png")},
+ headers={"Idempotency-Key": "reply-527:attachment"},
+ )
+
+ assert response.status_code == 201
+ assert response.json()["markdown"] == "![proof.png]()"
+ assert calls == [(527, "proof.png", "image/png", PNG)]
+ assert main.request_body_limit("POST", "/api/v1/notifications/527/attachments") == 2 * 1024 * 1024 + 64 * 1024
+
+
+@pytest.mark.anyio
+async def test_proxy_notification_upload_trusts_only_matching_gitea_subject_path():
+ requests = []
+
+ async def handler(request):
+ requests.append(request)
+ if request.method == "GET":
+ return httpx.Response(200, json={
+ "repository": {"full_name": "stackchain/web"},
+ "subject": {
+ "type": "Pull",
+ "url": "http://127.0.0.1:3000/api/v1/repos/stackchain/web/pulls/31",
+ },
+ })
+ return httpx.Response(201, json={
+ "name": "proof.png", "size": len(PNG),
+ "browser_download_url": "https://forge.example/a/proof.png",
+ })
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(handler))
+ try:
+ result = await gitea_proxy.upload_notification_attachment(527, "proof.png", "image/png", PNG)
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert [(request.method, request.url.path) for request in requests] == [
+ ("GET", "/api/v1/notifications/threads/527"),
+ ("POST", "/api/v1/repos/stackchain/web/issues/31/assets"),
+ ]
+ assert result["url"] == "https://forge.example/a/proof.png"
+
+
+@pytest.mark.anyio
+async def test_update_reply_composer_offers_mobile_safe_removable_screenshot_preview():
+ html = await dashboard()
+
+ assert 'id="update-reply-attachment"' in html
+ assert 'accept="image/png,image/jpeg,image/webp"' in html
+ assert 'id="update-reply-attachment-preview"' in html
+ assert 'id="remove-update-reply-attachment"' in html
+ assert "const updateReplyAttachmentController = issueAttachment.mount({" in html
+ assert "await updateReplyAttachmentController.serialize()" in html
+ assert ".update-reply .issue-attachment-preview { width:100%; min-width:0; }" in html
+ assert "@media (max-width:390px)" in html
+
+
+def test_authored_outbox_durably_keeps_account_bound_update_screenshot_out_of_localstorage():
+ script = f"""
+const createOutbox=require({json.dumps(str(OUTBOX))});
+const values=new Map();const mirrored=[];
+const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
+const outbox=createOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
+ reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}},
+}}}});
+(async()=>{{const result=await outbox.enqueueDurably({{
+ kind:'update-reply-read',notificationId:527,body:'',operationId:'reply-image',
+ attachment:{{filename:'phone.png',contentType:'image/png',blob:new Blob(['private-bytes'],{{type:'image/png'}})}},
+}});process.stdout.write(JSON.stringify({{
+ result,local:outbox.list()[0],raw:values.get('stackchain.authored-outbox.v1'),
+ durable:{{ownerLogin:mirrored[0][0].ownerLogin,text:await mirrored[0][0].attachment.blob.text()}},
+}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["local"]["attachment"] == {
+ "filename": "phone.png", "contentType": "image/png", "stored": True
+ }
+ assert output["durable"] == {"ownerLogin": "timmy", "text": "private-bytes"}
+ assert "private-bytes" not in output["raw"]
+
+
+def test_update_screenshot_is_not_admitted_without_indexeddb_durability():
+ script = f"""
+const createOutbox=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=createOutbox({{storage,getOwnerLogin:()=>'timmy'}});
+(async()=>{{let error='';try{{await outbox.enqueueDurably({{
+ kind:'update-reply',notificationId:527,body:'proof',operationId:'no-db',
+ attachment:{{filename:'phone.png',contentType:'image/png',data:'private-bytes'}},
+}});}}catch(caught){{error=caught.message;}}
+process.stdout.write(JSON.stringify({{error,items:outbox.list(),raw:values.get('stackchain.authored-outbox.v1')}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["error"] == "Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry."
+ assert output["items"] == []
+ assert "private-bytes" not in output["raw"]
+
+
+def test_background_update_reply_screenshot_checkpoints_upload_then_reply_then_read():
+ script = f"""
+const createSync=require({json.dumps(str(SYNC))});
+let item={{id:'reply-image',operationId:'reply-image',ownerLogin:'timmy',status:'queued',
+ kind:'update-reply-read',notificationId:527,body:'',replyConfirmed:false,
+ attachment:{{filename:'phone.webp',contentType:'image/webp',blob:new Blob(['pixels'],{{type:'image/webp'}})}}}};
+const calls=[];let replyAttempts=0;
+const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
+ complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
+const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
+ calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':options.body?JSON.parse(options.body):null}});
+ if(url.endsWith('/attachments'))return{{markdown:''}};
+ if(url.endsWith('/reply') && replyAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
+ return url.endsWith('/reply')?{{id:8}}:{{status:'read'}};
+}};
+(async()=>{{const sync=createSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
+ const checkpoint={{attachmentMarkdown:item.attachmentMarkdown,replyConfirmed:item.replyConfirmed}};
+ const result=await sync.flush();process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
+"""
+ output = run_node(script)
+
+ assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.webp]")
+ assert output["checkpoint"]["replyConfirmed"] is False
+ assert [call["url"] for call in output["calls"]] == [
+ "api/v1/notifications/527/attachments",
+ "api/v1/notifications/527/reply",
+ "api/v1/notifications/527/reply",
+ "api/v1/notifications/527/read",
+ ]
+ assert [call["key"] for call in output["calls"][:3]] == [
+ "reply-image:attachment", "reply-image:reply", "reply-image:reply"
+ ]
+ assert output["calls"][2]["body"] == {"body": ""}
+ assert output["result"]["confirmed"] == [{"status": "read"}]