Merge pull request 'Resume addressable mobile Search sessions' (#782) from timmy/781-resumable-mobile-search into main
All checks were successful
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s

Merge pull request Resume addressable mobile Search sessions (#782)
This commit is contained in:
timmy 2026-08-13 23:27:44 +00:00
commit fa3e5e3bf0
4 changed files with 221 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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