feat: queue offline Search replies (Closes #959)
Some checks failed
CI / lint (pull_request) Successful in 2m7s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Failing after 1m10s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-16 11:00:11 +00:00
parent 2e6c20d68d
commit 7fa43a9e50
9 changed files with 209 additions and 22 deletions

View File

@ -77,9 +77,13 @@ query, filters, results, and scroll position. If assignment succeeds but Later s
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success. remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
can send photo-only or text-plus-photo replies without leaving their Search pass. Each upload and the final can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
comment use stable idempotency keys: a failed attempt keeps the draft, evidence order, and confirmed upload Durable admission account-binds the exact issue/pull kind and stores image bytes in IndexedDB before the UI
checkpoints, while **Send & next** advances only after Gitea confirms the comment. clears or advances. Reconnect revalidates the visible Search target through the narrower preview attachment
and comment routes instead of assigned-work routes. Existing per-photo upload operation IDs and confirmed
Markdown checkpoints survive handoff, so retry resumes from the first unfinished photo and the final comment
posts once. **Send & next** advances only after durable admission; a storage failure keeps the current result,
text, evidence, and Search position unchanged.
Named mobile Search views preserve the query, type, status, and optional repository scope. They are Named mobile Search views preserve the query, type, status, and optional repository scope. They are
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently

View File

@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
); );
const pending = new Map(); const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']); const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
function messageAttachments(message) { function messageAttachments(message) {
const values = Array.isArray(message?.attachments) ? message.attachments : const values = Array.isArray(message?.attachments) ? message.attachments :
@ -19,6 +19,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
contentType: String(value.contentType || ''), contentType: String(value.contentType || ''),
stored: true, stored: true,
...(note ? { note } : {}), ...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
}; };
} }
@ -28,6 +30,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
filename: String(value.filename || ''), filename: String(value.filename || ''),
contentType: String(value.contentType || ''), contentType: String(value.contentType || ''),
...(note ? { note } : {}), ...(note ? { note } : {}),
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }), ...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
}; };
} }
@ -81,6 +85,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
function enqueue(message, mirror = true) { function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.'); if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
throw new Error('Choose an exact Search result before queueing a reply.');
}
const ownerLogin = String(getOwnerLogin() || '').trim(); const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.'); if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
const items = read(); const items = read();
@ -128,8 +135,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin, ownerLogin,
status: 'queued', status: 'queued',
queuedAt: Number(now()), queuedAt: Number(now()),
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}), ...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ? ...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } : (attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
{ attachments:attachments.map(attachmentMetadata) }) : {}), { attachments:attachments.map(attachmentMetadata) }) : {}),
...(message.kind === 'pull-review' ? { ...(message.kind === 'pull-review' ? {
@ -164,7 +172,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const previousItems = read(); const previousItems = read();
const item = enqueue(message, false); const item = enqueue(message, false);
const attachments = messageAttachments(message); const attachments = messageAttachments(message);
if (attachments.length && ['update-reply', 'update-reply-read'].includes(message.kind) && if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) { (!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false); write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.'); throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
@ -233,6 +241,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'pull-review') { if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
} }
if (item.kind === 'search-reply') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues'; const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments'; return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
} }

View File

@ -464,6 +464,13 @@ function createBackgroundIssueSync({
item, item,
); );
} }
if (item.kind === 'search-reply') {
return authoredRequest(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
item,
);
}
return { return {
url: base + 'api/v1/repos/' + repository + '/issues', url: base + 'api/v1/repos/' + repository + '/issues',
options: { options: {
@ -520,17 +527,20 @@ function createBackgroundIssueSync({
async function uploadConversationAttachments(item, url) { async function uploadConversationAttachments(item, url) {
let current = item; let current = item;
const attachments = conversationAttachments(current); const attachments = conversationAttachments(current);
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
const markdowns = Array.isArray(current.attachmentMarkdowns) ? const markdowns = Array.isArray(current.attachmentMarkdowns) ?
current.attachmentMarkdowns.slice(0, attachments.length) : current.attachmentMarkdowns.slice(0, attachments.length) :
(current.attachmentMarkdown ? [current.attachmentMarkdown] : []); (current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
for (let index = markdowns.length; index < attachments.length; index += 1) { for (let index = markdowns.length; index < attachments.length; index += 1) {
const uploaded = await requestStage(current, url, { const uploaded = await requestStage(current, url, {
method:'POST', method:'POST',
headers:{ headers:{
Accept:'application/json', Accept:'application/json',
'Idempotency-Key':stageOperationId( 'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index, current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
), )).slice(0, 128),
}, },
body:attachmentMultipart(attachments[index]), body:attachmentMultipart(attachments[index]),
}); });
@ -695,6 +705,29 @@ function createBackgroundIssueSync({
); );
} }
async function deliverSearchReply(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let current = item;
let attachmentMarkdown = '';
if (conversationAttachments(current).length) {
const uploaded = await uploadConversationAttachments(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
current = uploaded.current;
attachmentMarkdown = uploaded.markdown;
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
method:'POST',
headers:{Accept:'application/json','Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
body:JSON.stringify({body:attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
});
}
async function deliverUpdateScreenshotReply(item) { async function deliverUpdateScreenshotReply(item) {
const uploaded = await uploadConversationAttachments(item, const uploaded = await uploadConversationAttachments(item,
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments'); base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
@ -714,6 +747,7 @@ function createBackgroundIssueSync({
const request = deliveryRequest(item); const request = deliveryRequest(item);
try { try {
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) : const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'search-reply' ? await deliverSearchReply(item) :
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) : item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ? conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ? await deliverScreenshotComment(item) : item.attachment && !item.kind ?

View File

@ -5134,6 +5134,17 @@
{ method:'PATCH', headers:{ Accept:'application/json' } } { method:'PATCH', headers:{ Accept:'application/json' } }
), ),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto), ...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
queueReply:async (item,body,operationId) => {
searchReplyAttachmentTarget = item;
const serialized = await searchReplyAttachmentController.serialize();
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
const admitted = await authoredOutbox.enqueueDurably({
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
body,operationId,...(attachments.length ? {attachments} : {}),
});
renderOutbox();
return { id:admitted.item.id, queued:true };
},
prepareReply:(item,body) => { prepareReply:(item,body) => {
searchReplyAttachmentTarget = item; searchReplyAttachmentTarget = item;
return searchReplyAttachmentController.prepareComment(item, body); return searchReplyAttachmentController.prepareComment(item, body);

View File

@ -84,14 +84,15 @@
buttons.forEach(button => { buttons.forEach(button => {
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.()); button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
}); });
if (replying) status.textContent = 'Sending reply…'; if (replying) status.textContent = 'Saving reply for delivery…';
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
else if (state.status === 'replied') status.textContent = 'Reply posted.'; else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent = else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.'; state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
}; };
} }
})(typeof globalThis !== 'undefined' ? globalThis : this, function () { })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) { if (Array.isArray(session)) {
getSession = session[0]; getSession = session[0];
loadMore = () => session[1].loadMore(); loadMore = () => session[1].loadMore();
@ -289,7 +290,9 @@
}, },
reply({ advance = false } = {}) { reply({ advance = false } = {}) {
if (replyRequest) return replyRequest; if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.')); if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
return Promise.reject(new Error('Replying is unavailable.'));
}
const body = api.replyDraft().trim(); const body = api.replyDraft().trim();
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.')); if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
const item = { ...current }; const item = { ...current };
@ -300,20 +303,24 @@
save(operationKey, operationId); save(operationKey, operationId);
} }
publish({ status:'replying', item:current, detail:current, conversation }); publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = Promise.resolve( replyRequest = (typeof queueReply === 'function' ?
typeof prepareReply === 'function' ? prepareReply(item, body) : body Promise.resolve(queueReply(item, body, operationId)) :
).then(preparedBody => { Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.'); .then(preparedBody => {
return postReply(item, preparedBody, operationId); if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
}).then(async comment => { return postReply(item, preparedBody, operationId);
})
).then(async comment => {
await afterReply?.(item); await afterReply?.(item);
const comments = [...(conversation?.comments || [])]; if (!comment?.queued) {
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment); const comments = [...(conversation?.comments || [])];
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null }; if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
}
save(replyKey(item, 'draft'), ''); save(replyKey(item, 'draft'), '');
save(operationKey, ''); save(operationKey, '');
clearAttachments?.(); clearAttachments?.();
publish({ status:'replied', item:current, detail:current, conversation, result:comment }); publish({ status:comment?.queued ? 'queued' : 'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment; return advance ? api.next().then(() => comment) : comment;
}).catch(error => { }).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error }); publish({ status:'reply-error', item:current, detail:current, conversation, error });

View File

@ -111,6 +111,46 @@ const photo=(filename,note)=>({{filename,contentType:'image/png',blob:new Blob([
] ]
def test_search_preview_reply_is_durably_bound_to_exact_kind_with_upload_checkpoints():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
}});
(async()=>{{
const result=await outbox.enqueueDurably({{
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,
body:'Ready offline.',operationId:'search-42',attachments:[{{
filename:'proof.jpg',contentType:'image/jpeg',blob:new Blob(['proof']),note:'After repair',
operationId:'photo-stage-1',confirmed:{{markdown:'![proof](saved)'}},
}}],
}});
const metadata=outbox.list()[0]; const durable=mirrors[0][0];
process.stdout.write(JSON.stringify({{
item:result.item,metadata,
durable:{{targetKind:durable.targetKind,operationId:durable.attachment.operationId,
markdown:durable.attachment.confirmed?.markdown,hasBlob:durable.attachment.blob instanceof Blob}},
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["item"]["kind"] == "search-reply"
assert output["item"]["targetKind"] == "pull"
assert output["metadata"]["attachment"] == {
"filename": "proof.jpg", "contentType": "image/jpeg", "stored": True,
"note": "After repair", "operationId": "photo-stage-1",
"confirmed": {"markdown": "![proof](saved)"},
}
assert output["durable"] == {
"targetKind": "pull", "operationId": "photo-stage-1",
"markdown": "![proof](saved)", "hasBlob": True,
}
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content(): def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
script = f""" script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});

View File

@ -19,6 +19,55 @@ def run_node(script: str) -> dict:
return json.loads(completed.stdout) return json.loads(completed.stdout)
def test_search_reply_reconnect_uses_preview_authorization_and_resumes_photo_checkpoints():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'search-42',operationId:'search-42',ownerLogin:'timmy',status:'queued',outboxLane:'authored',
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,body:'Ready offline.',attachments:[
{{filename:'one.jpg',contentType:'image/jpeg',blob:new Blob(['one']),operationId:'photo-one',confirmed:{{markdown:'![one](saved)'}}}},
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two']),operationId:'photo-two'}},
]}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(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'}};
const file=options.body instanceof FormData?options.body.get('file'):null;
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
body:file?null:JSON.parse(options.body)}});
if(file)return{{markdown:'![two](uploaded)'}};
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
return{{id:91,body:'Ready offline.'}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';
try{{await sync.flush();}}catch(error){{first=error.message;}}
const checkpoint={{...item}};const second=await sync.flush();
process.stdout.write(JSON.stringify({{calls,first,checkpoint,second}}));
}})();
"""
output = run_node(script)
assert output["first"] == "offline"
assert output["checkpoint"]["attachmentMarkdowns"] == [
"![one](saved)", "![two](uploaded)"
]
uploads = [call for call in output["calls"] if call["filename"]]
assert [(call["filename"], call["key"]) for call in uploads] == [
("two.jpg", "photo-two")
]
comments = [call for call in output["calls"] if not call["filename"]]
assert [call["url"] for call in comments] == [
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
]
assert [call["key"] for call in comments] == ["search-42:comment"] * 2
assert comments[-1]["body"] == {
"body": "Ready offline.\n\n![one](saved)\n\n![two](uploaded)"
}
assert output["second"]["confirmed"] == [{"id": 91, "body": "Ready offline."}]
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key(): def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
script = f""" script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -389,6 +389,33 @@ process.stdout.write(JSON.stringify({{
} }
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async()=>{{
const items=[1,2].map(number=>({{repository:'stackchain/api',number,kind:'issue'}}));
const values=new Map();let admit;const navigated=[];const states=[];let cleared=0;
const preview=createSearchPreview({{
fetchJson:async item=>item,mutate:async()=>{{}},
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
}});
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
queued:states.some(state=>state.status==='queued')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
}
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation(): def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
script = f""" script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});

View File

@ -130,6 +130,9 @@ def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
assert mount assert mount
assert "maxFiles: 5" in mount.group(1) assert "maxFiles: 5" in mount.group(1)
assert "'/preview/attachments?kind='" in mount.group(1) assert "'/preview/attachments?kind='" in mount.group(1)
assert "queueReply:async (item,body,operationId)" in dashboard
assert "kind:'search-reply',targetKind:item.kind" in dashboard
assert "authoredOutbox.enqueueDurably" in dashboard
assert ".search-preview-reply .conversation-photo-actions" in css assert ".search-preview-reply .conversation-photo-actions" in css