feat: pause Today during mobile Search (Closes #1078)
All checks were successful
CI / lint (pull_request) Successful in 3m5s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m21s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-18 12:45:08 +00:00
parent ff34416c3c
commit d4910b4398
7 changed files with 209 additions and 4 deletions

View File

@ -137,6 +137,9 @@ textarea { resize: vertical; min-height: 120px; }
#cmd-palette.open { display: block; } #cmd-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; } .cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
.cmd-palette-header-actions { display:flex; gap:8px; } .cmd-palette-header-actions { display:flex; gap:8px; }
.search-today-interruption { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.search-today-interruption[hidden] { display:none; }
.search-today-interruption button { min-height:44px; flex:0 0 auto; }
.cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; } .cmd-search-scope { display:grid; grid-template-columns:auto minmax(120px,1fr) auto minmax(120px,1fr); gap:6px 10px; align-items:center; margin:8px 0 0; padding:8px; border:1px solid #1f3a5f; border-radius:8px; }
.cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; } .cmd-search-scope legend { padding:0 4px; color:#93a4b8; font-size:12px; }
.cmd-search-scope label { font-size:12px; color:#cbd5e1; } .cmd-search-scope label { font-size:12px; color:#cbd5e1; }

View File

@ -1839,6 +1839,7 @@
formatEstimate: formatPlanMinutes, formatEstimate: formatPlanMinutes,
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity), getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
onReopen: identity => { onReopen: identity => {
taskOverlayHistory.leave();
selectTodayWork(); selectTodayWork();
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity)); workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
}, },
@ -5433,9 +5434,9 @@
if (shareStatus[state.status]) status.textContent = shareStatus[state.status]; if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
else if (state.status === 'reopening') status.textContent = 'Reopening…'; else if (state.status === 'reopening') status.textContent = 'Reopening…';
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…'; else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…'; else if (state.status === 'claimed') status.textContent = 'Assigned. Opening My Work…';
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.'; else if (detail.claimable) status.textContent = 'Open and unassigned.';
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.'; else if (detail.assigned_to_me) status.textContent = 'Already in My Work.';
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.'; else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
else status.textContent = 'Ready.'; else status.textContent = 'Ready.';
} }

View File

@ -521,6 +521,10 @@
<button id="close-command-palette" type="button">Close</button> <button id="close-command-palette" type="button">Close</button>
</div> </div>
</div> </div>
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
<strong data-search-today-label>Today paused</strong>
<button data-return-from-search type="button">Return to Today</button>
</aside>
<input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." /> <input id="cmd-input" type="text" role="combobox" aria-autocomplete="list" aria-controls="cmd-results" aria-expanded="false" placeholder="Search commands, issues, and pull requests..." />
<fieldset class="cmd-search-scope"> <fieldset class="cmd-search-scope">
<legend>Filter search results</legend> <legend>Filter search results</legend>
@ -598,6 +602,10 @@
</div> </div>
<button id="close-search-preview" type="button">Back to search</button> <button id="close-search-preview" type="button">Back to search</button>
</div> </div>
<aside class="search-today-interruption" data-search-today-interruption role="status" aria-live="polite" hidden>
<strong data-search-today-label>Today paused</strong>
<button data-return-from-search type="button">Return to Today</button>
</aside>
<nav class="mobile-search-preview-nav" aria-label="Search preview sections"> <nav class="mobile-search-preview-nav" aria-label="Search preview sections">
<button type="button" data-search-preview-section="overview">Overview</button> <button type="button" data-search-preview-section="overview">Overview</button>
<button type="button" data-search-preview-section="conversation">Conversation</button> <button type="button" data-search-preview-section="conversation">Conversation</button>

View File

@ -5,7 +5,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
}; };
const empty = () => ({ const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null, version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null, capture_interruption:null, timed_break:null, pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, timed_break:null,
}); });
const read = () => { const read = () => {
const ownerKey = key(); const ownerKey = key();
@ -48,6 +48,12 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
typeof pending.resume === 'boolean' ? typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null; { identity:pending.identity, resume:pending.resume } : null;
}; };
const validSearch = state => {
const pending = state.search_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const validBreak = state => { const validBreak = state => {
const value = state.timed_break; const value = state.timed_break;
return value && typeof value.identity === 'string' && value.identity && return value && typeof value.identity === 'string' && value.identity &&
@ -94,6 +100,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null; state.away_at = null;
state.pending_interruption = null; state.pending_interruption = null;
state.attention_interruption = null; state.attention_interruption = null;
state.search_interruption = null;
state.timed_break = null; state.timed_break = null;
return write(state); return write(state);
}, },
@ -110,6 +117,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null; state.away_at = null;
state.pending_interruption = null; state.pending_interruption = null;
state.attention_interruption = null; state.attention_interruption = null;
state.search_interruption = null;
state.timed_break = null; state.timed_break = null;
return write(state); return write(state);
}, },
@ -161,6 +169,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null; state.away_at = null;
state.pending_interruption = null; state.pending_interruption = null;
state.attention_interruption = null; state.attention_interruption = null;
state.search_interruption = null;
state.timed_break = null; state.timed_break = null;
return write(state); return write(state);
}, },
@ -232,6 +241,44 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
state.away_at = null; state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null; return write(state) ? { identity:pending.identity, resumed } : null;
}, },
beginSearch() {
const state = read();
const existing = validSearch(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry?.running) return null;
const resume = true;
settle(state);
state.away_at = null;
state.search_interruption = { identity, resume };
return write(state) ? { ...state.search_interruption } : null;
},
searchInterruption() {
return validSearch(read());
},
abandonSearch() {
const state = read();
const pending = validSearch(state);
if (!pending) return null;
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:false } : null;
},
returnFromSearch() {
const state = read();
const pending = validSearch(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
const resumed = pending.resume && state.active_identity === pending.identity;
if (resumed && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.search_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed } : null;
},
markAway() { markAway() {
const state = read(); const state = read();
const entry = state.entries[state.active_identity]; const entry = state.entries[state.active_identity];
@ -317,6 +364,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
getItem, getItem,
onReturn:() => render(), onReturn:() => render(),
}); });
const searchView = createTodaySearchInterruption({
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
onReturn:identity => onReopen?.(identity),
});
queryAll('[data-mobile-today-open]').forEach(button => queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => { button.addEventListener('click', () => {
const identity = timer.snapshot().identity; const identity = timer.snapshot().identity;
@ -424,6 +475,8 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
else if (!active) timer.stop(); else if (!active) timer.stop();
render(); render();
}, },
reopen(identity) { onReopen?.(identity); },
search(action, ...args) { return searchView[action]?.(...args); },
finish() { timer.stop(); progress = null; runway = null; render(); }, finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); }, update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); }, reset() { progress = null; runway = null; render(); },
@ -449,6 +502,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
onCapture?.(); onCapture?.();
})); }));
if (timer.captureInterruption) view.restoreCapture(); if (timer.captureInterruption) view.restoreCapture();
searchView.restore();
return view; return view;
} }
@ -501,6 +555,55 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
}; };
} }
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
const render = (pending = timer.searchInterruption?.()) => {
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
queryAll('[data-search-today-label]').forEach(element => {
element.textContent = pending ? 'Today paused · ' +
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
});
onChange?.();
return pending;
};
const view = {
open() {
const wasRunning = timer.snapshot().running;
const pending = timer.beginSearch?.();
render(pending || timer.searchInterruption?.());
if (wasRunning && !pending) {
const status = queryAll('#cmd-search-action-status')[0];
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
}
return pending;
},
restore() { return render(); },
finish() {
const result = timer.returnFromSearch?.();
render(null);
return result;
},
transfer() {
const result = timer.abandonSearch?.();
render(null);
return result;
},
};
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
const pending = timer.searchInterruption?.();
view.finish();
if (pending) onReturn?.(pending.identity);
}));
if (typeof MutationObserver !== 'undefined') {
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
const observer = new MutationObserver(() => {
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
});
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
}
return view;
}
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) { function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption'); banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label'); label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
@ -539,6 +642,7 @@ if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView; createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt; createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan; createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption; createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
module.exports = createTodayTimer; module.exports = createTodayTimer;
} }

View File

@ -120,6 +120,30 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
assert resumed == {"running": True, "saved": True} assert resumed == {"running": True, "saved": True}
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator('[data-mobile-task="search"]').click()
search_pause = page.locator('#cmd-palette [data-search-today-interruption]')
expect(search_pause).to_be_visible()
expect(search_pause).to_contain_text("Today paused · Ship mobile capture")
return_button = search_pause.locator('[data-return-from-search]')
bounds = return_button.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === false &&
timer.search_interruption?.identity === timer.active_identity;
}""")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
return_button.click()
expect(page.locator("#cmd-palette")).not_to_have_class("open")
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.search_interruption;
}""")
page.locator("#close-issue-sheet").click()
page.locator("[data-mobile-today-more]").click() page.locator("[data-mobile-today-more]").click()
expect(page.locator("#mobile-today-actions")).to_be_visible() expect(page.locator("#mobile-today-actions")).to_be_visible()
blocked = page.locator("[data-mobile-today-blocked]") blocked = page.locator("[data-mobile-today-blocked]")

View File

@ -951,6 +951,17 @@ def test_search_preview_offers_assign_and_start_for_eligible_issues():
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_mobile_search_pauses_today_and_offers_a_lossless_return():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert html.count('<aside class="search-today-interruption" data-search-today-interruption') == 2
assert html.count('<button data-return-from-search type="button">') == 2
assert "taskOverlayHistory.leave();" in html
assert ".search-today-interruption" in css
assert ".search-today-interruption button { min-height:44px;" in css
def test_search_preview_queues_eligible_issue_and_continues_preserved_search(): def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
html = dashboard_bundle_text() html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text() css = (FRONTEND / "dashboard.css").read_text()

View File

@ -173,3 +173,57 @@ process.stdout.write(JSON.stringify({opened, shown, finished, closed, transferre
"transferred": {"identity": "issue:r:1:", "resumed": False}, "transferred": {"identity": "issue:r:1:", "resumed": False},
"final": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False}, "final": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
} }
def test_search_leaves_manually_paused_work_alone_and_cannot_restore_replaced_work():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 1000;
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
timer.adopt('issue:r:1:', 3000, false);
const pausedSearch = timer.beginSearch();
timer.activate('issue:r:1:');
const runningSearch = timer.beginSearch();
timer.activate('issue:r:2:');
const replacedReturn = timer.returnFromSearch();
process.stdout.write(JSON.stringify({pausedSearch, runningSearch, replacedReturn, snapshot:timer.snapshot()}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"pausedSearch": None,
"runningSearch": {"identity": "issue:r:1:", "resume": True},
"replacedReturn": None,
"snapshot": {"identity": "issue:r:2:", "elapsed_ms": 0, "running": True},
}
def test_search_pauses_running_today_time_and_restores_exactly_once():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 1000;
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
timer.activate('issue:r:42:');
now = 6000;
const interruption = timer.beginSearch();
now = 26000;
const paused = timer.snapshot();
const returned = timer.returnFromSearch();
now = 28000;
const restored = timer.snapshot();
const repeated = timer.returnFromSearch();
process.stdout.write(JSON.stringify({interruption, paused, returned, restored, repeated}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"interruption": {"identity": "issue:r:42:", "resume": True},
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
"returned": {"identity": "issue:r:42:", "resumed": True},
"restored": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
"repeated": None,
}