feat: show conversations in mobile search preview (Closes #803)
All checks were successful
CI / lint (pull_request) Successful in 1m45s
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:04:25 +00:00
parent 2406d24cb8
commit 51fe9e5af2
7 changed files with 281 additions and 16 deletions

View File

@ -546,6 +546,10 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-conversation { display:grid; gap:8px; padding-top:8px; border-top:1px solid #2a496e; }
.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-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

@ -4322,11 +4322,8 @@
commandSearch.setScope(scope);
}
let searchPreviewDetail = null;
function searchPreviewPath(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
}
const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
function renderSearchPreview(state) {
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
@ -4358,6 +4355,7 @@
qs('#search-preview-title').textContent = state.item.title || 'Work preview';
qs('#search-preview-meta').textContent = '';
qs('#search-preview-body').textContent = '';
renderSearchConversation(null);
qs('#open-search-result-gitea').href = safeSearchUrl(state.item.url) || '#';
status.textContent = 'Loading preview…';
return;
@ -4378,6 +4376,7 @@
(detail.labels?.length ? ' · ' + detail.labels.join(', ') : '') +
(detail.assignees?.length ? ' · assigned to ' + detail.assignees.join(', ') : '');
qs('#search-preview-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
renderSearchConversation(state.conversation);
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';
@ -4409,6 +4408,9 @@
}
const searchPreview = createSearchPreview({
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
fetchConversation:(item,page)=>fetchReviewJson(searchPreviewConversationPath(item,page),{
headers:{Accept:'application/json'},
}),
mutate: (detail, action) => fetchReviewJson(
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
@ -4430,15 +4432,9 @@
},
formatTime: fmt,
});
function canonicalSearchPreviewUrl() {
const { query, preview, scope = currentSearchScope() } = taskOverlayHistory.currentState();
const params = new URLSearchParams({
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state,
});
if (scope.repository) params.set('search_repository', scope.repository);
return new URL('?' + params, window.location.origin + window.location.pathname).href;
}
const canonicalSearchPreviewUrl = () => searchPreviewUrl(
taskOverlayHistory.currentState(), currentSearchScope(), window.location
);
function createSearchStart(claim) {
return createAssignAndStart({
available: createAndStart.available,

View File

@ -465,6 +465,13 @@
<div id="search-preview-status" class="small" aria-live="polite">Choose a search result.</div>
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<section id="search-preview-conversation" class="search-preview-conversation" aria-labelledby="search-preview-conversation-title">
<h2 id="search-preview-conversation-title">Current conversation</h2>
<div id="search-preview-comments"></div>
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
<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>
<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

@ -9,9 +9,57 @@
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
};
root.searchPreviewUrl = (state, defaultScope, location) => {
const { query, preview, scope = defaultScope } = state;
const params = new URLSearchParams({
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
search_kind:scope.kind, search_state:scope.state,
});
if (scope.repository) params.set('search_repository', scope.repository);
return new URL('?' + params, location.origin + location.pathname).href;
};
root.searchPreviewPath = item => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview?kind=' + encodeURIComponent(item.kind);
};
root.searchPreviewConversationPath = (item, page) => {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
if (page) query.set('page', String(page));
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
'/preview/conversation?' + query.toString();
};
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
const retry = document.querySelector('#retry-search-preview-conversation');
const older = document.querySelector('#load-older-search-preview-comments');
if (!conversation) {
comments.textContent = status.textContent = '';
retry.hidden = older.hidden = true;
return;
}
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') + '</div>' +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
older.disabled = conversation.status === 'loading';
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
? 'Loading older messages…' : 'Loading current conversation…';
else if (conversation.status === 'error') status.textContent =
'Conversation unavailable. Preview and planning actions still work.';
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
else status.textContent = conversation.comments.length +
(conversation.comments.length === 1 ? ' message.' : ' messages.');
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function createSearchPreview({ fetchJson, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
if (Array.isArray(session)) {
getSession = session[0];
loadMore = () => session[1].loadMore();
@ -23,6 +71,7 @@
let mutationRequest = null;
let shareRequest = null;
let moveRequest = null;
let conversation = null;
function sameItem(left, right) {
return left && right && left.kind === right.kind && left.repository === right.repository &&
@ -72,16 +121,53 @@
return mutationRequest;
}
function loadConversation(detail, requestGeneration, page) {
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
const previousComments = page && Array.isArray(conversation?.comments)
? conversation.comments : [];
conversation = { status:'loading', comments:previousComments, olderPage:page ?? null };
publish({ status:'ready', item:current, detail, conversation });
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;
conversation = {
status:'ready',
comments,
olderPage:result?.older_page ?? null,
};
publish({ status:'ready', item:current, detail:current, conversation });
return result;
}).catch(error => {
if (requestGeneration === generation) {
conversation = {
status:'error', comments:previousComments,
olderPage:page ?? conversation?.olderPage ?? null, error,
};
publish({ status:'ready', item:current, detail:current, conversation });
}
return null;
});
}
const api = {
open(item) {
generation += 1;
const requestGeneration = generation;
current = { ...item };
conversation = null;
publish({ status: 'loading', item: current });
return fetchJson(current).then(detail => {
if (requestGeneration === generation) {
current = { ...current, ...detail };
publish({ status: 'ready', item: current, detail });
if (typeof fetchConversation === 'function') {
loadConversation(current, requestGeneration);
} else {
publish({ status: 'ready', item: current, detail });
}
}
return detail;
}).catch(error => {
@ -128,6 +214,14 @@
})().finally(() => { moveRequest = null; });
return moveRequest;
},
retryConversation() {
if (!current) return Promise.resolve(null);
return loadConversation(current, generation);
},
loadOlderConversation() {
if (!current || !conversation?.olderPage) return Promise.resolve(null);
return loadConversation(current, generation, conversation.olderPage);
},
share(url) {
if (shareRequest) return shareRequest;
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
@ -152,6 +246,8 @@
if (navigationRoot) {
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
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());
}
return api;
};

View File

@ -2778,6 +2778,32 @@ async def global_search_preview(
return JSONResponse(preview)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/preview/conversation")
async def global_search_preview_conversation(
owner: str,
repo: str,
number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(),
page: int | None = Query(default=None, ge=1, le=100),
limit: int = Query(default=20, ge=1, le=50),
) -> JSONResponse:
del kind # Issue and pull-request conversations share Gitea's issue-comments API.
try:
conversation = await asyncio.wait_for(
gitea_proxy.issue_conversation_page(
f"{owner}/{repo}", number, page, limit
),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "This conversation is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(conversation)
@app.get("/api/v1/work-route")
async def resolve_work_route(
kind: Literal["issue", "pull", "review", "update"] = Query(),

View File

@ -386,6 +386,26 @@ process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.numb
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
preview_source = SEARCH_PREVIEW.read_text()
assert 'id="search-preview-conversation"' in html
assert 'id="search-preview-comments"' in html
assert 'id="search-preview-conversation-status"' in html
assert 'id="retry-search-preview-conversation"' in html
assert 'id="load-older-search-preview-comments"' in html
assert 'aria-live="polite"' in html
assert "fetchConversation:" in html
assert "api.retryConversation()" in preview_source
assert "api.loadOlderConversation()" in preview_source
assert "renderMarkdown(comment.body || '')" in preview_source
assert ".search-preview-conversation" in css
assert ".search-preview-comment" in css
assert "min-height:44px" in css
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
@ -440,6 +460,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
const pending = new Map();
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
await new Promise(resolve => setTimeout(resolve, 0));
const final = states.at(-1);
process.stdout.write(JSON.stringify({{
number:final.detail.number,
status:final.conversation.status,
comments:final.conversation.comments.map(comment => comment.body),
}}));
}})().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) == {
"number": 2,
"status": "ready",
"comments": ["current"],
}
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0;
const states = [];
const preview = createSearchPreview({{
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
fetchConversation: (_item, page) => {{
calls += 1;
if (calls === 1) return Promise.reject(new Error('offline'));
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
}},
mutate: () => Promise.resolve(),
onState: state => states.push(state),
}});
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
await preview.retryConversation();
await preview.loadOlderConversation();
const final = states.at(-1).conversation;
process.stdout.write(JSON.stringify({{
calls,
status:final.status,
ids:final.comments.map(comment => comment.id),
olderPage:final.olderPage,
}}));
}})().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": 3,
"status": "ready",
"ids": [1, 3, 4],
"olderPage": None,
}
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});

View File

@ -186,6 +186,43 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
monkeypatch, kind
):
requested = []
async def conversation(repository, number, page, limit):
requested.append((repository, number, page, limit))
return {
"comments": [{
"id": 7,
"author": "alexander",
"body": "Current **decision**",
"created_at": "2026-08-14T12:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
}],
"page": 3,
"older_page": 2,
"total": 41,
}
monkeypatch.setattr(
main.gitea_proxy, "issue_conversation_page", conversation, raising=False
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/api/v1/repos/stackchain/api/issues/42/preview/conversation?kind={kind}&limit=20"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["comments"][0]["body"] == "Current **decision**"
assert requested == [("stackchain/api", 42, None, 20)]
@pytest.mark.anyio
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
requests = []