Continue through mobile Search results while planning work #802

Merged
timmy merged 1 commits from timmy/801-sequential-mobile-search into main 2026-08-14 05:13:10 +00:00
6 changed files with 241 additions and 28 deletions

View File

@ -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);
},
};
};

View File

@ -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; }

View File

@ -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.';
}

View File

@ -466,6 +466,11 @@
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<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>
<span id="search-preview-position" aria-live="polite"></span>
<button id="next-search-result" type="button">Next</button>
</nav>
<div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="defer-search-result" type="button" hidden>Assign &amp; defer</button>

View File

@ -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;
};
});

View File

@ -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