feat: reply and continue from mobile search (Closes #805)
All checks were successful
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-14 06:41:34 +00:00
parent 7bcab03b8a
commit 5a4fbd1564
8 changed files with 384 additions and 5 deletions

View File

@ -550,6 +550,12 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-conversation h2 { margin:0; font-size:1rem; }
.search-preview-comment { min-width:0; padding:10px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.search-preview-conversation button { min-height:44px; width:100%; }
.search-preview-reply { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.search-preview-reply[hidden] { display:none; }
.search-preview-reply h2 { margin:0; font-size:1rem; }
.search-preview-reply textarea { box-sizing:border-box; width:100%; min-height:110px; resize:vertical; scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom)); }
.search-preview-reply-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.search-preview-reply-actions button { min-height:44px; }
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }

View File

@ -4348,6 +4348,7 @@
startButton.hidden = true;
startButton.disabled = false;
shareButton.disabled = true;
renderSearchPreviewReply(state, null, searchPreview, document);
if (state.status === 'loading') {
searchPreviewDetail = null;
@ -4377,6 +4378,7 @@
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
renderSearchPreviewReply(state, detail, searchPreview, document);
qs('#open-search-result-gitea').href = safeSearchUrl(detail.url) || '#';
claimButton.hidden = !(detail.claimable || detail.assigned_to_me);
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
@ -4416,6 +4418,7 @@
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
{ method:'PATCH', headers:{ Accept:'application/json' } }
),
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
onState: renderSearchPreview,

View File

@ -472,6 +472,15 @@
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section class="search-preview-reply" aria-labelledby="search-preview-reply-title" hidden>
<h2 id="search-preview-reply-title">Reply</h2>
<textarea id="search-preview-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
<div class="search-preview-reply-actions">
<button id="send-search-preview-reply" type="button">Send</button>
<button id="send-search-preview-reply-next" type="button">Send &amp; next</button>
</div>
</section>
<div class="search-preview-actions">
<nav class="search-preview-navigation" aria-label="Search result navigation" hidden>
<button id="previous-search-result" type="button">Previous</button>

View File

@ -30,6 +30,16 @@
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
'/comments?kind=' + encodeURIComponent(item.kind);
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
storage,
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
@ -57,9 +67,29 @@
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
const input = document.querySelector('#search-preview-reply');
const status = document.querySelector('#search-preview-reply-status');
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
.map(selector => document.querySelector(selector));
section.hidden = detail?.commentable !== true;
status.textContent = '';
if (section.hidden) {
buttons.forEach(button => { button.disabled = true; });
return;
}
input.value = preview.replyDraft();
const replying = state.status === 'replying';
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
if (replying) status.textContent = 'Sending reply…';
else if (state.status === 'replied') status.textContent = 'Reply posted.';
else if (state.status === 'reply-error') status.textContent =
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@ -71,6 +101,7 @@
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let replyRequest = null;
let conversation = null;
function sameItem(left, right) {
@ -78,6 +109,23 @@
Number(left.number) === Number(right.number);
}
function replyKey(item, suffix) {
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
}
function stored(key) {
try { return storage?.getItem(key) || ''; }
catch (_) { return ''; }
}
function save(key, value) {
try {
if (value) storage?.setItem(key, value);
else storage?.removeItem(key);
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
}
function navigation(item) {
const session = typeof getSession === 'function' ? getSession() : null;
const items = Array.isArray(session?.items) ? session.items : [];
@ -130,10 +178,9 @@
return fetchConversation(detail, page).then(result => {
if (requestGeneration !== generation) return result;
const incoming = Array.isArray(result?.comments) ? result.comments : [];
const comments = page
? [...incoming, ...previousComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index)
: incoming;
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
all.findIndex(candidate => candidate?.id === comment?.id) === index);
conversation = {
status:'ready',
comments,
@ -222,6 +269,44 @@
if (!current || !conversation?.olderPage) return Promise.resolve(null);
return loadConversation(current, generation, conversation.olderPage);
},
saveReplyDraft(body) {
if (!current) return '';
const key = replyKey(current, 'draft');
const next = String(body || '');
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
save(key, next);
return next;
},
replyDraft() {
return current ? stored(replyKey(current, 'draft')) : '';
},
reply({ advance = false } = {}) {
if (replyRequest) return replyRequest;
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
const body = api.replyDraft().trim();
if (!body) return Promise.reject(new Error('Write a reply first.'));
const item = { ...current };
const operationKey = replyKey(item, 'operation');
let operationId = stored(operationKey);
if (!operationId) {
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
save(operationKey, operationId);
}
publish({ status:'replying', item:current, detail:current, conversation });
replyRequest = postReply(item, body, operationId).then(comment => {
const comments = [...(conversation?.comments || [])];
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
save(replyKey(item, 'draft'), '');
save(operationKey, '');
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
return advance ? api.next().then(() => comment) : comment;
}).catch(error => {
publish({ status:'reply-error', item:current, detail:current, conversation, error });
throw error;
}).finally(() => { replyRequest = null; });
return replyRequest;
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
@ -248,6 +333,17 @@
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
const reply = navigationRoot.querySelector('#search-preview-reply');
reply?.addEventListener('input', event => {
api.saveReplyDraft(event.target.value);
const disabled = !event.target.value.trim();
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
});
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
api.reply({ advance:true }).catch(() => {}));
}
return api;
};

View File

@ -586,6 +586,7 @@ async def work_preview(repository: str, kind: str, number: int) -> dict:
"reopenable": actual_kind == "issue" and state == "closed",
"assigned_to_me": bool(login and login in assignee_names),
"reviewable": reviewable,
"commentable": bool(login),
}

View File

@ -2804,6 +2804,48 @@ async def global_search_preview_conversation(
return JSONResponse(conversation)
@app.post(
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/comments",
status_code=201,
)
async def comment_on_global_search_preview(
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
idempotency_key: str | None = Header(default=None, max_length=128),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def post_comment():
preview = await gitea_proxy.work_preview(repository, kind, number)
if (
preview.get("repository") != repository
or preview.get("kind") != kind
or preview.get("number") != number
):
raise HTTPException(status_code=404, detail="Search result not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await _run_idempotent_authored_action(
post_comment(),
idempotency_key=idempotency_key,
fingerprint=("search-preview-comment", repository, kind, number, comment.body),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The reply could not be posted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),

View File

@ -86,6 +86,23 @@ def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
assert '#cmd-search-repository' in css
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview = SEARCH_PREVIEW.read_text()
assert '<section class="search-preview-reply"' in html
assert '<textarea id="search-preview-reply"' in html
assert '<button id="send-search-preview-reply"' in html
assert '<button id="send-search-preview-reply-next"' in html
assert "searchPreviewReplyOptions(fetchReviewJson" in html
assert "root.searchPreviewReplyPath(item)" in preview
assert "api.saveReplyDraft" in preview
assert "api.reply({ advance:true })" in preview
assert ".search-preview-reply textarea" in css
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@ -327,6 +344,132 @@ process.stdout.write(JSON.stringify({{
}
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const calls = [];
const states = [];
let attempt = 0;
const preview = createSearchPreview({{
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
mutate: () => Promise.resolve(),
postReply: (candidate, body, operationId) => {{
calls.push([candidate.number, body, operationId]);
attempt += 1;
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
}});
}},
storage,
createOperationId: () => 'reply-operation-42',
onState: state => states.push(state),
}});
await preview.open(item);
preview.saveReplyDraft('Ready to merge.');
try {{ await preview.reply(); }} catch (_) {{}}
const draftAfterFailure = preview.replyDraft();
await preview.reply();
process.stdout.write(JSON.stringify({{
calls,
draftAfterFailure,
draftAfterSuccess:preview.replyDraft(),
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
}}));
}})().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) == {
"calls": [
[42, "Ready to merge.", "reply-operation-42"],
[42, "Ready to merge.", "reply-operation-42"],
],
"draftAfterFailure": "Ready to merge.",
"draftAfterSuccess": "",
"comments": [{
"id": 91,
"author": "timmy",
"body": "Ready to merge.",
"created_at": "2026-08-14T12:30:00Z",
}],
}
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
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 confirm;
const navigated = [];
const preview = createSearchPreview({{
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:() => 'reply-next-1',
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
onNavigate:item => navigated.push(item.number), onState:() => {{}},
}});
await preview.open(items[0]);
preview.saveReplyDraft('Done.');
const pending = preview.reply({{advance:true}});
await Promise.resolve();
const before = [...navigated];
confirm({{id:7,author:'timmy',body:'Done.'}});
await pending;
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
}})().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": [],
"after": [2],
"draft": "",
}
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const values = new Map();
let finishConversation;
const states = [];
const preview = createSearchPreview({{
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
mutate:() => Promise.resolve(),
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
}});
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
preview.saveReplyDraft('Confirmed reply');
await preview.reply();
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
await Promise.resolve();
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
}})().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) == [3, 9]
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});

View File

@ -223,6 +223,84 @@ async def test_global_search_preview_conversation_returns_a_bounded_authorized_p
assert requested == [("stackchain/api", 42, None, 20)]
@pytest.mark.anyio
async def test_global_search_preview_reply_is_idempotent_for_an_authorized_pull(monkeypatch):
main._idempotency_ledger.clear()
previews = []
comments = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return {
"repository": repository,
"kind": "pull",
"number": number,
"title": "Review mobile reply",
"state": "open",
}
async def comment(repository, number, body):
comments.append((repository, number, body))
return {
"id": 91,
"author": "timmy",
"body": body,
"created_at": "2026-08-14T12:30:00Z",
"url": "https://forge.example/stackchain/api/pulls/42#issuecomment-91",
}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "search-reply-pull-42"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Ready to merge."},
headers=headers,
)
replay = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Ready to merge."},
headers=headers,
)
assert first.status_code == replay.status_code == 201
assert replay.json() == first.json()
assert previews == [("stackchain/api", "pull", 42)]
assert comments == [("stackchain/api", 42, "Ready to merge.")]
@pytest.mark.anyio
async def test_global_search_preview_reply_rejects_a_mismatched_target_before_commenting(monkeypatch):
main._idempotency_ledger.clear()
comments = []
async def preview(repository, kind, number):
return {
"repository": repository,
"kind": "issue",
"number": number,
"title": "Different target",
}
async def comment(*args):
comments.append(args)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/42/preview/comments?kind=pull",
json={"body": "Do not misroute this."},
headers={"Idempotency-Key": "search-reply-mismatch-42"},
)
assert response.status_code == 404
assert comments == []
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []
@ -420,6 +498,7 @@ async def test_work_preview_normalizes_details_and_only_allows_unassigned_open_i
"reopenable": False,
"assigned_to_me": False,
"reviewable": False,
"commentable": True,
}