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