From bc0d47e8e70542db27b5260bf602db8901461aaf Mon Sep 17 00:00:00 2001 From: timmy Date: Thu, 13 Aug 2026 23:25:35 +0000 Subject: [PATCH] feat: resume addressable mobile search sessions (Closes #781) --- frontend/dashboard.js | 18 +++- frontend/task-overlay-history.js | 142 ++++++++++++++++++++++++----- src/frontend_bundle.py | 2 +- tests/test_task_overlay_history.py | 85 +++++++++++++++++ 4 files changed, 221 insertions(+), 26 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 58f69f5..365f1f0 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -4451,7 +4451,7 @@ mobileSearchViewport.rememberScroll(); searchPreviewReturnKind = 'search'; searchPreview.open(item.result).catch(() => {}); - taskOverlayHistory.open('search-preview'); + taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result }); } qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').setAttribute('aria-expanded', 'false'); @@ -4496,7 +4496,7 @@ const taskOverlayHistory = createTaskOverlayHistory({ history: window.history, eventTarget: window, - onChange(kind, previous) { + onChange(kind, previous, detail) { if (previous === 'new' && kind !== 'new') { if (!suppressCreateDraftOnHistoryClose) saveIssueCaptureDraft(); suppressCreateDraftOnHistoryClose = false; @@ -4531,7 +4531,18 @@ } if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false); if (kind === 'find' && previous !== 'find') openFindWorkSheet(false); - if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false); + if (kind === 'search' && previous !== 'search-preview') { + if (detail?.query !== undefined) { + qs('#cmd-input').value = detail.query; + commandSearch.setQuery(detail.query); + } + openCommandPalette(false); + } + if (kind === 'search-preview' && detail?.preview && previous !== 'search') { + if (detail?.query !== undefined) qs('#cmd-input').value = detail.query; + searchPreviewReturnKind = 'search'; + searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close()); + } if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false); if (kind === 'today-readiness' && previous !== 'today-readiness') renderTodayReadiness(todayReadiness.snapshot()); }, @@ -4541,6 +4552,7 @@ qs('#close-command-palette').addEventListener('click', () => taskOverlayHistory.close()); qs('#cmd-input').addEventListener('input', (e) => { commandSelection = -1; + taskOverlayHistory.update({ query:e.target.value }); renderCommands(e.target.value); commandSearch.setQuery(e.target.value); }); diff --git a/frontend/task-overlay-history.js b/frontend/task-overlay-history.js index f82d7de..fd28fd4 100644 --- a/frontend/task-overlay-history.js +++ b/frontend/task-overlay-history.js @@ -6,21 +6,104 @@ 'use strict'; const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']); + const searchKinds = new Set(['search', 'search-preview']); + const maxQueryLength = 200; - return function createTaskOverlayHistory({ history, eventTarget, onChange }) { - let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null; + function cleanQuery(value) { + const query = typeof value === 'string' ? value.trim() : ''; + return query.length <= maxQueryLength ? query : ''; + } + + function cleanPreview(value) { + if (!value || !['issue', 'pull'].includes(value.kind)) return null; + const repository = typeof value.repository === 'string' ? value.repository : ''; + const parts = repository.split('/'); + const validPart = part => /^[A-Za-z0-9_.-]+$/.test(part); + const number = Number(value.number); + if (parts.length !== 2 || !parts.every(validPart) || !Number.isInteger(number) || number < 1) return null; + return { kind:value.kind, repository, number }; + } + + function previewToken(preview) { + return preview ? `${preview.kind}:${preview.repository}:${preview.number}` : ''; + } + + function parsePreview(value) { + const match = /^(issue|pull):([^/:]+\/[^/:]+):(\d+)$/.exec(value || ''); + return match ? cleanPreview({ kind:match[1], repository:match[2], number:Number(match[3]) }) : null; + } + + return function createTaskOverlayHistory({ history, location, eventTarget, onChange }) { let started = false; + let restoredFromUrl = false; - function stateKind(state = history.state) { - return allowed.has(state?.taskOverlay) ? state.taskOverlay : null; + function stateDetail(state = history.state) { + const kind = allowed.has(state?.taskOverlay) ? state.taskOverlay : null; + if (!searchKinds.has(kind)) return { kind }; + const query = cleanQuery(state.searchQuery); + const preview = kind === 'search-preview' ? cleanPreview(state.searchPreview) : null; + if (kind === 'search-preview' && state.searchPreview !== undefined && !preview) { + return { kind:'search', ...(query ? { query } : {}) }; + } + return { kind, ...(query ? { query } : {}), ...(preview ? { preview } : {}) }; } + function urlDetail() { + if (!location || typeof URLSearchParams === 'undefined') return { kind:null }; + const params = new URLSearchParams(location.search || ''); + const rawQuery = params.get('search'); + if (rawQuery === null) return { kind:null }; + const query = cleanQuery(rawQuery); + if (rawQuery.trim() && !query) return { kind:null }; + const preview = parsePreview(params.get('preview')); + return { + kind:preview ? 'search-preview' : 'search', + ...(query ? { query } : {}), + ...(preview ? { preview } : {}), + }; + } + + function toState(detail, base = history.state) { + const state = { ...(base || {}), taskOverlay:detail.kind }; + delete state.searchQuery; + delete state.searchPreview; + if (searchKinds.has(detail.kind)) { + if (detail.query) state.searchQuery = detail.query; + if (detail.kind === 'search-preview' && detail.preview) state.searchPreview = detail.preview; + } + return state; + } + + function urlFor(detail) { + if (!location || typeof URLSearchParams === 'undefined') return undefined; + const params = new URLSearchParams(location.search || ''); + params.delete('search'); + params.delete('preview'); + if (searchKinds.has(detail.kind)) { + params.set('search', detail.query || ''); + if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview)); + } + const query = params.toString(); + return `${location.pathname || ''}${query ? `?${query}` : ''}${location.hash || ''}`; + } + + let initial = stateDetail(); + if (!initial.kind) { + const fromUrl = urlDetail(); + if (fromUrl.kind) { + initial = fromUrl; + restoredFromUrl = true; + history.replaceState(toState(fromUrl), '', urlFor(fromUrl)); + } + } + let active = initial; + function apply(state) { - const next = stateKind(state); - if (next === active) return; - const previous = active; + const next = stateDetail(state); + if (JSON.stringify(next) === JSON.stringify(active)) return; + const previous = active.kind; active = next; - onChange(next, previous); + onChange(next.kind, previous, next); } return { @@ -28,33 +111,48 @@ if (started) return; started = true; eventTarget.addEventListener('popstate', event => apply(event.state)); + if (restoredFromUrl) onChange(active.kind, null, active); }, - open(kind) { + open(kind, detail = {}) { if (!allowed.has(kind)) return false; - if (active === kind) return true; - const previous = active; - const state = { ...(history.state || {}), taskOverlay: kind }; - history.pushState(state, ''); - active = kind; - onChange(kind, previous); + const next = stateDetail(toState({ kind, query:cleanQuery(detail.query), preview:cleanPreview(detail.preview) })); + if (active.kind === next.kind && JSON.stringify(active) === JSON.stringify(next)) return true; + const previous = active.kind; + history.pushState(toState(next), '', urlFor(next)); + active = next; + onChange(next.kind, previous, next); + return true; + }, + update(detail = {}) { + if (!searchKinds.has(active.kind)) return false; + const next = stateDetail(toState({ + kind:active.kind, + query:detail.query === undefined ? active.query : cleanQuery(detail.query), + preview:detail.preview === undefined ? active.preview : cleanPreview(detail.preview), + })); + history.replaceState(toState(next), '', urlFor(next)); + active = next; return true; }, close() { - if (!active) return false; + if (!active.kind) return false; history.back(); return true; }, leave() { - if (!active) return false; - const previous = active; + if (!active.kind) return false; + const previous = active.kind; const state = { ...(history.state || {}) }; delete state.taskOverlay; - history.replaceState(state, ''); - active = null; - onChange(null, previous); + delete state.searchQuery; + delete state.searchPreview; + active = { kind:null }; + history.replaceState(state, '', urlFor(active)); + onChange(null, previous, active); return true; }, - current() { return active; }, + current() { return active.kind; }, + currentState() { return { ...active, ...(active.preview ? { preview:{ ...active.preview } } : {}) }; }, }; }; }); diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 2545d5d..1898242 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -28,7 +28,7 @@ FEATURE_SOURCES = { "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), "security-center": ("static/security-center.js",), "today-timer": ( - "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", + "static/task-overlay-history.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/pick-work.js", "static/batch-find-work.js", diff --git a/tests/test_task_overlay_history.py b/tests/test_task_overlay_history.py index 363cfe5..2676048 100644 --- a/tests/test_task_overlay_history.py +++ b/tests/test_task_overlay_history.py @@ -212,3 +212,88 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history(): assert "taskOverlayHistory.close()" in html assert "saveIssueCaptureDraft();" in html assert "history.replaceState(history.state || {}, '', cleanUrl)" in html + + +def test_search_history_serializes_bounded_query_and_stable_preview_identity(): + script = f""" +const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); +const pushed = []; +const replaced = []; +const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }}; +const history = {{ + state: {{ page:'dashboard' }}, + pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }}, + replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }}, + back() {{}}, +}}; +const controller = createTaskOverlayHistory({{ + history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, +}}); +controller.open('search', {{ query:' release blocker ' }}); +controller.update({{ query:'release candidate' }}); +controller.open('search-preview', {{ + query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123, + title:'must not be serialized', body:'secret'}}, +}}); +process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert len(payload["pushed"]) == 2 + assert len(payload["replaced"]) == 1 + assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker" + assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate" + assert payload["pushed"][1][1] == ( + "/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123" + ) + assert payload["current"] == { + "kind": "search-preview", + "query": "release candidate", + "preview": {"kind": "issue", "repository": "stackchain/api", "number": 123}, + } + + +def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state(): + script = f""" +const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); +function restore(search) {{ + const changes = []; + const location = {{ pathname:'/dashboard/', search, hash:'' }}; + const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }}; + const controller = createTaskOverlayHistory({{ + history, location, eventTarget:{{addEventListener() {{}}}}, + onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }}, + }}); + controller.start(); + return {{ state:controller.currentState(), changes }}; +}} +process.stdout.write(JSON.stringify({{ + valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'), + malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'), + oversized:restore('?search=' + 'x'.repeat(201)), +}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + expected = { + "kind": "search-preview", + "query": "release blocker", + "preview": {"kind": "pull", "repository": "stackchain/api", "number": 9}, + } + assert payload["valid"]["state"] == expected + assert payload["valid"]["changes"] == [["search-preview", None, expected]] + assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"} + assert payload["oversized"]["state"] == {"kind": None} + + +def test_dashboard_persists_search_query_and_restores_canonical_preview(): + html = dashboard_bundle_text() + + assert "taskOverlayHistory.update({ query:e.target.value })" in html + assert "taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result })" in html + assert "detail?.query" in html + assert "searchPreview.open(detail.preview).catch" in html -- 2.43.0