feat: show conversations in mobile search preview (Closes #803)
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 committed 2026-08-14 06:04:25 +00:00
1 parent 2406d24cb8
commit 51fe9e5af2
7 files changed
+281 -16

No files matched your search

+4
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; }
+10 -14
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,
+7
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>
+98 -2
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;
};