From 745d2529e52918bf9483761d9c8a191a16cd587c Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 05:08:56 +0000 Subject: [PATCH] feat: continue through mobile search results (Closes #801) --- frontend/commands.js | 9 +-- frontend/dashboard.css | 1 + frontend/dashboard.js | 15 ++-- frontend/index.html | 5 ++ frontend/search-preview.js | 111 +++++++++++++++++++++++++---- tests/test_command_palette.py | 128 +++++++++++++++++++++++++++++++++- 6 files changed, 241 insertions(+), 28 deletions(-) diff --git a/frontend/commands.js b/frontend/commands.js index 8ae3879..569a071 100644 --- a/frontend/commands.js +++ b/frontend/commands.js @@ -28,6 +28,7 @@ function publish(next) { state = next; onState(next); + return state; } async function requestPage(query, page, current, append) { @@ -42,7 +43,7 @@ const items = [...new Map((combined || []).map(item => [`${item.kind}:${item.repository}:${item.number}`, item] )).values()]; - if (current === generation) publish({ + if (current === generation) return publish({ status: 'ready', query, items, partial, more: !!result.has_more, next: result.next_page, @@ -50,7 +51,7 @@ }); } catch (error) { if (error && error.name === 'AbortError') return; - if (current === generation) publish(append + if (current === generation) return publish(append ? { ...state, status: 'ready' } : { status: 'error', query, items: [], error, scope:{ ...scope } }); } finally { @@ -89,10 +90,10 @@ this.setQuery(query); }, loadMore() { - if (state.status !== 'ready' || !state.more || activeController) return; + if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state); const current = generation; const page = state.next; - requestPage(state.query, page, current, true); + return requestPage(state.query, page, current, true); }, }; }; diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 9cf0ad0..cfa833e 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -546,6 +546,7 @@ 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-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; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index b72b7c8..39e318b 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -2252,7 +2252,7 @@ onConfirm: (item, until, context) => { if (context === 'search') { return () => searchDefer.run(item, until).then(outcome => { - if (outcome === 'deferred') closeSearchPreview(); + if (outcome === 'deferred') return next(); }).catch(error => { qs('#search-preview-status').textContent = error.message + ' Retry.'; }); @@ -4321,13 +4321,6 @@ qs('#cmd-search-repository').value = scope.repository || ''; commandSearch.setScope(scope); } - function safeSearchUrl(value) { - try { - const url = new URL(value); - return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; - } catch (_) { return ''; - } - } let searchPreviewDetail = null; function searchPreviewPath(item) { const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); @@ -4422,6 +4415,7 @@ { method:'PATCH', headers:{ Accept:'application/json' } } ), share: url => createWorkRoute.share(url, navigator, navigator.clipboard), + session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})], onState: renderSearchPreview, }); searchDefer = createSearchDefer({ @@ -4489,6 +4483,7 @@ renderCommands(qs('#cmd-input').value); qs('#cmd-input').focus(); } + const next = searchPreview.next; async function openPreviewWorkInMyWork(detail) { await load(); const item = lastMyWork.find(candidate => @@ -4776,14 +4771,14 @@ try { if (todayWork.contains(detail)) { qs('#cmd-search-action-status').textContent = 'Already in Today.'; - closeSearchPreview(); + await next(); return; } const outcome = await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me, destination: 'queue', }); - if (outcome === 'queued') closeSearchPreview(); + if (outcome === 'queued') await next(); } catch (error) { qs('#search-preview-status').textContent = error.message + ' Retry.'; } diff --git a/frontend/index.html b/frontend/index.html index 9b66e95..d09fc25 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -466,6 +466,11 @@
+
diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 7b662ed..03724de 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -1,42 +1,92 @@ (function (root, factory) { const createSearchPreview = factory(); if (typeof module === 'object' && module.exports) module.exports = createSearchPreview; - if (root) root.createSearchPreview = createSearchPreview; + if (root) { + root.createSearchPreview = createSearchPreview; + root.safeSearchUrl = value => { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) ? url.href : ''; + } catch (_) { return ''; } + }; + } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, mutate, share, onState }) { + return function createSearchPreview({ fetchJson, mutate, share, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { + if (Array.isArray(session)) { + getSession = session[0]; + loadMore = () => session[1].loadMore(); + onNavigate = session[2]; + navigationRoot = document; + } let generation = 0; let current = null; let mutationRequest = null; let shareRequest = null; + let moveRequest = null; + + function sameItem(left, right) { + return left && right && left.kind === right.kind && left.repository === right.repository && + Number(left.number) === Number(right.number); + } + + function navigation(item) { + const session = typeof getSession === 'function' ? getSession() : null; + const items = Array.isArray(session?.items) ? session.items : []; + const index = items.findIndex(candidate => sameItem(candidate, item)); + if (index < 0) return null; + return { + position:index + 1, + total:items.length, + hasPrevious:index > 0, + hasNext:index < items.length - 1 || session.more === true, + }; + } + + function publish(state) { + const position = navigation(state.item || current); + if (navigationRoot) { + const bar = navigationRoot.querySelector('.search-preview-navigation'); + bar.hidden = !position; + if (position) { + navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total; + navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious; + navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext; + } + } + onState(position ? { ...state, navigation:position } : state); + if (state.complete && navigationRoot) { + navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.'; + } + } function run(action, pending, success, detail) { if (mutationRequest) return mutationRequest; - onState({ status: pending, item: current, detail }); + publish({ status: pending, item: current, detail }); mutationRequest = mutate(detail, action).then(result => { - onState({ status: success, item: current, detail, result }); + publish({ status: success, item: current, detail, result }); return result; }).catch(error => { - onState({ status: 'ready', item: current, detail, error }); + publish({ status: 'ready', item: current, detail, error }); throw error; }).finally(() => { mutationRequest = null; }); return mutationRequest; } - return { + const api = { open(item) { generation += 1; const requestGeneration = generation; current = { ...item }; - onState({ status: 'loading', item: current }); + publish({ status: 'loading', item: current }); return fetchJson(current).then(detail => { if (requestGeneration === generation) { current = { ...current, ...detail }; - onState({ status: 'ready', item: current, detail }); + publish({ status: 'ready', item: current, detail }); } return detail; }).catch(error => { if (requestGeneration === generation) { - onState({ status: 'error', item: current, error }); + publish({ status: 'error', item: current, error }); } throw error; }); @@ -46,16 +96,48 @@ current = null; onState({ status: 'closed' }); }, + previous() { + const session = typeof getSession === 'function' ? getSession() : null; + const items = Array.isArray(session?.items) ? session.items : []; + const index = items.findIndex(item => sameItem(item, current)); + if (index <= 0) return Promise.resolve(current); + const item = items[index - 1]; + if (typeof onNavigate === 'function') onNavigate(item); + return api.open(item); + }, + next() { + if (moveRequest) return moveRequest; + moveRequest = (async () => { + let session = typeof getSession === 'function' ? getSession() : null; + let items = Array.isArray(session?.items) ? session.items : []; + let index = items.findIndex(item => sameItem(item, current)); + if (index < 0) return current; + if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') { + await loadMore(); + session = getSession(); + items = Array.isArray(session?.items) ? session.items : []; + index = items.findIndex(item => sameItem(item, current)); + } + if (index >= 0 && index < items.length - 1) { + const item = items[index + 1]; + if (typeof onNavigate === 'function') onNavigate(item); + return api.open(item); + } + publish({ status:'ready', item:current, detail:current, complete:true }); + return current; + })().finally(() => { moveRequest = null; }); + return moveRequest; + }, share(url) { if (shareRequest) return shareRequest; if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.')); const detail = current; - onState({ status:'sharing', item:current, detail }); + publish({ status:'sharing', item:current, detail }); shareRequest = share(url).then(result => { - onState({ status:result, item:current, detail }); + publish({ status:result, item:current, detail }); return result; }).catch(error => { - onState({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error }); + publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error }); throw error; }).finally(() => { shareRequest = null; }); return shareRequest; @@ -67,5 +149,10 @@ return run('reopen', 'reopening', 'reopened', detail); }, }; + if (navigationRoot) { + navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {})); + navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {})); + } + return api; }; }); diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index 02a83a4..2d8456c 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -290,6 +290,129 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath() ) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile" +def test_search_preview_navigates_a_loaded_result_session_and_reports_position(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})); +const states = []; +const navigated = []; +const preview = createSearchPreview({{ + fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}), + mutate: () => Promise.resolve(), + getSession: () => ({{items, more:false}}), + loadMore: () => Promise.resolve(), + onNavigate: item => navigated.push(item.number), + onState: state => states.push(state), +}}); +await preview.open(items[1]); +await preview.previous(); +await preview.next(); +const ready = states.filter(state => state.status === 'ready'); +process.stdout.write(JSON.stringify({{ + navigated, + positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total, + state.navigation.hasPrevious, state.navigation.hasNext]), + current:ready.at(-1).detail.number, +}})); +}})().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) == { + "navigated": [1, 2], + "positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]], + "current": 2, + } + + +def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion(): + script = f""" +const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); +(async () => {{ +let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}}; +let loads = 0; +const states = []; +const preview = createSearchPreview({{ + fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(), + getSession: () => session, + loadMore: async () => {{ + loads += 1; + session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}}; + }}, + onNavigate() {{}}, onState: state => states.push(state), +}}); +await preview.open(session.items[1]); +await preview.next(); +await preview.next(); +const final = states.at(-1); +process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete, + navigation:final.navigation}})); +}})().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) == { + "loads": 1, + "current": 3, + "complete": True, + "navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False}, + } + + +def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation(): + script = f""" +const commands = require({json.dumps(str(COMMANDS))}); +(async () => {{ +const states = []; +const controller = commands.createGlobalSearchController({{ + delay:0, + search: async (_query, _signal, page) => page === 1 + ? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}} + : {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}}, + onState: state => states.push(state), +}}); +controller.setQuery('mobile'); +await new Promise(resolve => setTimeout(resolve, 5)); +const loaded = await controller.loadMore(); +process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}})); +}})().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) == {"numbers": [1, 2], "more": False} + + +def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning(): + html = dashboard_bundle_text() + css = (FRONTEND / "dashboard.css").read_text() + + assert 'id="previous-search-result"' in html + assert 'id="search-preview-position"' in html + assert 'id="next-search-result"' in html + assert 'aria-label="Search result navigation"' in html + assert ".search-preview-navigation" in css + assert ".search-preview-header button, .search-preview-actions button" in css + assert "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" in html + preview_source = SEARCH_PREVIEW.read_text() + assert "api.previous()" in preview_source + assert "api.next()" in preview_source + + defer_flow = html.split("if (context === 'search')", 1)[1].split( + "if (context === 'detail')", 1 + )[0] + queue_flow = html.split( + "qs('#queue-search-result').addEventListener('click'", 1 + )[1].split("qs('#start-search-result')", 1)[0] + assert "next()" in defer_flow + assert "next()" in queue_flow + assert "closeSearchPreview()" not in defer_flow + assert "if (outcome === 'queued') await next()" in queue_flow + + def test_search_preview_ignores_stale_result_details(): script = f""" const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); @@ -514,7 +637,7 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues(): assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css -def test_search_preview_queues_eligible_issue_and_returns_to_preserved_search(): +def test_search_preview_queues_eligible_issue_and_continues_preserved_search(): html = dashboard_bundle_text() css = (FRONTEND / "dashboard.css").read_text() @@ -530,7 +653,8 @@ def test_search_preview_queues_eligible_issue_and_returns_to_preserved_search(): )[0] assert "destination: 'queue'" in handler assert "alreadyOwned: detail.assigned_to_me" in handler - assert "closeSearchPreview()" in handler + assert "next()" in handler + assert "closeSearchPreview()" not in handler assert "mobileSearchViewport.restoreScroll()" in html assert "@media (max-width:420px)" in css assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css -- 2.43.0