Merge pull request 'Pause Today during mobile Security Center' (#1087) from timmy/1086-pause-today-security-center into main
All checks were successful
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m42s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
rockachopa 2026-08-18 16:49:47 +00:00
commit 1c6f5c685f
11 changed files with 174 additions and 14 deletions

View File

@ -1853,6 +1853,7 @@
}, },
onCapture:() => openCreateIssueSheet(), onCapture:() => openCreateIssueSheet(),
}); });
window.stackchainTodayTimerView = timerView;
mobileInsights.start(); mobileInsights.start();
todaySessionSync = attachTodaySessionHandoff({ todaySessionSync = attachTodaySessionHandoff({
fetchJson:fetchReviewJson, storage:localStorage, timer, qs, fetchJson:fetchReviewJson, storage:localStorage, timer, qs,

View File

@ -51,6 +51,10 @@
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div> <div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button> <button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
</div> </div>
<aside class="today-detour-interruption" id="security-center-today-detour" data-today-detour role="status" aria-live="polite" hidden>
<strong data-today-detour-label>Today paused</strong>
<button id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>
</aside>
<nav class="security-section-nav" aria-label="Security Center sections"> <nav class="security-section-nav" aria-label="Security Center sections">
<button id="security-section-activity" type="button">Activity</button> <button id="security-section-activity" type="button">Activity</button>
<button id="security-section-devices" type="button">Devices</button> <button id="security-section-devices" type="button">Devices</button>

View File

@ -1,7 +1,7 @@
(function (root, factory) { (function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory; if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.attachSecurityCenter = boundary => factory({ root, boundary }); else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) { })(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
const devicesButton = root.document.getElementById('active-devices'); const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet'); const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list'); const devicesList = root.document.getElementById('active-devices-list');
@ -52,6 +52,7 @@
if (!devicesSheet.hidden) { if (!devicesSheet.hidden) {
devicesSheet.hidden = true; devicesSheet.hidden = true;
devicesButton?.focus(); devicesButton?.focus();
onClose();
} }
}); });
@ -222,6 +223,7 @@
const open = () => { const open = () => {
if (!devicesSheet) return; if (!devicesSheet) return;
onOpen();
devicesSheet.hidden = false; devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus(); root.document.getElementById('close-active-devices')?.focus();
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' }); navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });

View File

@ -45,6 +45,12 @@
const devicesSheet = root.document.getElementById('active-devices-sheet'); const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesStatus = root.document.getElementById('active-devices-status'); const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices'); const closeDevices = root.document.getElementById('close-active-devices');
const returnFromSecurity = root.document.getElementById('return-from-security-center');
const beginSecurityDetour = () => {
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
root.stackchainTodayTimerView?.beginDetour?.('security-center');
}
};
const securityFeatures = root.createFeatureLoader({ const securityFeatures = root.createFeatureLoader({
document: root.document, document: root.document,
urls: { urls: {
@ -57,6 +63,7 @@
const openSecurityCenter = async () => { const openSecurityCenter = async () => {
if (loadingSecurityCenter) return; if (loadingSecurityCenter) return;
loadingSecurityCenter = true; loadingSecurityCenter = true;
beginSecurityDetour();
devicesSheet.hidden = false; devicesSheet.hidden = false;
closeDevices?.focus(); closeDevices?.focus();
try { try {
@ -66,7 +73,10 @@
retryLabel: 'Tap Active devices to retry.', retryLabel: 'Tap Active devices to retry.',
}, () => { }, () => {
devicesButton.removeEventListener('click', openSecurityCenter); devicesButton.removeEventListener('click', openSecurityCenter);
root.attachSecurityCenter(boundary).open(); root.attachSecurityCenter(boundary, {
onOpen: beginSecurityDetour,
onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'),
}).open();
}); });
} finally { } finally {
loadingSecurityCenter = false; loadingSecurityCenter = false;
@ -78,6 +88,11 @@
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => { if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true; devicesSheet.hidden = true;
devicesButton?.focus(); devicesButton?.focus();
root.stackchainTodayTimerView?.finishDetour?.('security-center');
});
if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
else devicesSheet.hidden = true;
}); });
boundary.refreshOfflineLease().then(valid => { boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork(); if (valid) boundary.resumeQueuedWork();

View File

@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
const validDetour = state => { const validDetour = state => {
const pending = state.detour_interruption; const pending = state.detour_interruption;
return pending && typeof pending.identity === 'string' && pending.identity && return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup'].includes(pending.reason) ? typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null; { identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
}; };
const validBreak = state => { const validBreak = state => {
@ -289,7 +289,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
return write(state) ? { identity:pending.identity, resumed } : null; return write(state) ? { identity:pending.identity, resumed } : null;
}, },
beginDetour(reason) { beginDetour(reason) {
if (!['find', 'queues', 'insights', 'device-setup'].includes(reason)) return null; if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
const state = read(); const state = read();
const existing = validDetour(state); const existing = validDetour(state);
if (existing) return existing; if (existing) return existing;
@ -304,9 +304,10 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
detourInterruption() { detourInterruption() {
return validDetour(read()); return validDetour(read());
}, },
returnFromDetour() { returnFromDetour(reason = '') {
const state = read(); const state = read();
const pending = validDetour(state); const pending = validDetour(state);
if (reason && pending?.reason !== reason) return null;
const entry = pending && state.entries[pending.identity]; const entry = pending && state.entries[pending.identity];
if (!pending || !entry || state.active_identity !== pending.identity) return null; if (!pending || !entry || state.active_identity !== pending.identity) return null;
const resumed = pending.resume && !entry.running; const resumed = pending.resume && !entry.running;
@ -521,7 +522,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
reopen(identity) { onReopen?.(identity); }, reopen(identity) { onReopen?.(identity); },
search(action, ...args) { return searchView[action]?.(...args); }, search(action, ...args) { return searchView[action]?.(...args); },
beginDetour(reason) { return detourView.open(reason); }, beginDetour(reason) { return detourView.open(reason); },
finishDetour() { return detourView.finish(); }, finishDetour(reason) { return detourView.finish(reason); },
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(); },
@ -701,9 +702,9 @@ function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange
return pending; return pending;
}, },
restore() { return render(); }, restore() { return render(); },
finish() { finish(reason) {
const result = timer.returnFromDetour?.(); const result = timer.returnFromDetour?.(reason);
render(null); if (result || !reason) render(null);
return result; return result;
}, },
}; };

View File

@ -446,6 +446,37 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
const timer = JSON.parse(localStorage.getItem(key)); const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption; return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""") }""")
page.locator("#close-issue-sheet").click()
page.locator("#app-menu-toggle").click()
page.locator("#active-devices").click()
security_pause = page.locator("#security-center-today-detour")
expect(security_pause).to_be_visible()
expect(security_pause).to_contain_text("Today paused · Ship mobile capture")
bounds = page.locator("#return-from-security-center").bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
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.detour_interruption?.reason === 'security-center';
}""")
page.locator("#security-section-devices").click()
page.go_back()
expect(security_pause).to_be_visible()
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
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.detour_interruption;
}""")
page.locator("#active-devices").click()
expect(security_pause).to_be_visible()
page.locator("#return-from-security-center").click()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
browser.close() browser.close()
finally: finally:
fake.shutdown() fake.shutdown()

View File

@ -12,6 +12,19 @@ SESSION_JS = ROOT / "frontend" / "session.js"
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js" SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
@pytest.mark.anyio
async def test_security_center_wires_mobile_today_pause_before_lazy_loading_and_visible_return():
html = await dashboard()
session = SESSION_JS.read_text()
dashboard_js = (ROOT / "frontend" / "dashboard.js").read_text()
assert 'id="security-center-today-detour" data-today-detour' in html
assert 'id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>' in html
assert session.index("beginDetour?.('security-center')") < session.index("devicesSheet.hidden = false;")
assert "onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center')" in session
assert "window.stackchainTodayTimerView = timerView;" in dashboard_js
def run_session_scenario(scenario: str) -> dict: def run_session_scenario(scenario: str) -> dict:
harness = f""" harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); const createSessionBoundary = require({json.dumps(str(SESSION_JS))});

View File

@ -78,8 +78,9 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions. # Core mobile workflows stay below 100 KiB, including synced Search, Update decisions,
assert len(first.runtime_gzip_bytes) <= 99 * 1024 # and interruption-safe Security Center navigation.
assert len(first.runtime_gzip_bytes) <= 100 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source

View File

@ -254,8 +254,8 @@ process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing(): async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
html = await dashboard() html = await dashboard()
assert html.count('data-today-detour role="status"') == 5 assert html.count('data-today-detour role="status"') == 6
assert html.count('data-return-from-detour type="button"') == 5 assert html.count('data-return-from-detour type="button"') == 6
assert "detour:() => timerView" in html assert "detour:() => timerView" in html
timer_source = TIMER.read_text() timer_source = TIMER.read_text()
assert "createTodayDetourInterruption" in timer_source assert "createTodayDetourInterruption" in timer_source

View File

@ -230,6 +230,49 @@ const boundary={{
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"] assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
def test_security_center_finishes_its_today_detour_only_when_the_sheet_closes():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const listeners={{}};
const element=()=>({{
hidden:true,textContent:'',children:[],attributes:{{}},
addEventListener(){{}},focus(){{}},replaceChildren(){{this.children=[];}},append(...values){{this.children.push(...values);}},
setAttribute(name,value){{this.attributes[name]=value;}},removeAttribute(name){{delete this.attributes[name];}},scrollIntoView(){{}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices',
'security-section-activity','security-section-devices','security-section-passkeys',
'security-activity-section','security-devices-section','security-passkeys-section'];
const ids=Object.fromEntries(names.map(id=>[id,element()]));
const root={{
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
history:{{state:null,pushState(state){{this.state=state;}}}},
location:{{pathname:'/dashboard/',search:''}},
addEventListener:(type,listener)=>listeners[type]=listener,
}};
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
let closes=0;
(async()=>{{
const controller=attachSecurityCenter({{root,boundary,onClose:()=>closes++}});
await controller.open();
listeners.popstate({{state:{{stackchainSecuritySection:'devices'}}}});
const afterSectionBack=closes;
listeners.popstate({{state:null}});
listeners.popstate({{state:null}});
console.log(JSON.stringify({{afterSectionBack,closes,hidden:ids['active-devices-sheet'].hidden}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"afterSectionBack": 0,
"closes": 1,
"hidden": True,
}
def test_close_uses_history_back_to_remove_security_center_route(): def test_close_uses_history_back_to_remove_security_center_route():
harness = f""" harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});

View File

@ -313,6 +313,26 @@ process.stdout.write(JSON.stringify({pausedDetour, runningDetour, replacedReturn
} }
def test_security_center_close_cannot_finish_an_unrelated_detour():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>1000});
timer.activate('issue:r:1:');
timer.beginDetour('insights');
const returned = timer.returnFromDetour('security-center');
process.stdout.write(JSON.stringify({returned, pending:timer.detourInterruption(), 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) == {
"returned": None,
"pending": {"identity": "issue:r:1:", "resume": True, "reason": "insights"},
"snapshot": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
}
def test_device_setup_detour_excludes_permission_time_and_survives_reload(): def test_device_setup_detour_excludes_permission_time_and_survives_reload():
script = SOURCE.read_text() + r""" script = SOURCE.read_text() + r"""
const values = new Map(); const values = new Map();
@ -342,6 +362,35 @@ process.stdout.write(JSON.stringify({interruption, restored, paused, returned, r
} }
def test_security_center_detour_excludes_investigation_time_and_resumes_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.beginDetour('security-center');
now = 26000;
const paused = timer.snapshot();
const returned = timer.returnFromDetour();
now = 28000;
const resumed = timer.snapshot();
const repeated = timer.returnFromDetour();
process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, 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, "reason": "security-center"},
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "security-center"},
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
"repeated": None,
}
def test_mobile_detour_view_names_work_and_returns_to_today(): def test_mobile_detour_view_names_work_and_returns_to_today():
script = SOURCE.read_text() + r""" script = SOURCE.read_text() + r"""
const values = new Map(); const values = new Map();