Attach screenshots to unread update replies #528

Merged
timmy merged 1 commits from timmy/527-update-reply-screenshots into main 2026-08-11 01:10:55 +00:00
21 changed files with 437 additions and 32 deletions

View File

@ -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' };
}

View File

@ -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);

View File

@ -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%; }

View File

@ -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 () => {

View File

@ -516,6 +516,15 @@
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="update-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="update-reply-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="update-reply-attachment-preview" hidden>
<img id="update-reply-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="update-reply-attachment-meta"></span>
<button id="remove-update-reply-attachment" type="button">Remove screenshot</button>
</div>
<div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
<button id="send-update-reply-read-next" type="button">Reply, mark read &amp; next</button>

View File

@ -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. */ }

View File

@ -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;

View File

@ -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;

View File

@ -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}",

View File

@ -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"![{safe_name}](<{safe_url}>)"
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)

View File

@ -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

View File

@ -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

View File

@ -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';",
)
)

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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](<https://forge.example/a/proof.webp>)"
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:'![phone.webp](https://forge.example/phone.webp)'}};
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": "![phone.webp](https://forge.example/phone.webp)"}
assert output["result"]["confirmed"] == [{"status": "read"}]