From 09dbdb996117804d2eefdea0549867ba60a67b0d Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 06:59:20 +0000 Subject: [PATCH] feat: preserve mobile work section history (Closes #1006) --- frontend/dashboard.js | 36 ++++++-- frontend/mobile-issue-detail-nav.js | 11 ++- frontend/mobile-review-detail-nav.js | 11 ++- frontend/mobile-update-detail-nav.js | 11 ++- frontend/work-route.js | 52 ++++++++++-- tests/test_mobile_issue_detail_navigation.py | 39 +++++++++ tests/test_mobile_review_detail_navigation.py | 32 ++++++++ tests/test_mobile_update_detail_navigation.py | 32 ++++++++ tests/test_my_work.py | 82 ++++++++++++++++++- 9 files changed, 282 insertions(+), 24 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 36b919c..77a52d6 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -27,6 +27,7 @@ actions:qs('#issue-planning'), }, planning:qs('#issue-planning'), + onSectionChange:(section, options) => workRoute.section(section, options), prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, }); mobileIssueDetailNavigation.start(); @@ -41,6 +42,7 @@ review:qs('#pull-review'), }, beforeNavigate:{review(target) { target.open = true; }}, + onSectionChange:(section, options) => workRoute.section(section, options), prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, }); mobilePullDetailNavigation.start(); @@ -1409,6 +1411,7 @@ }, replyComposer:qs('#update-reply'), jumpToNewActivity:() => updateReadPosition.jump(), + onSectionChange:(section, options) => workRoute.section(section, options), prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, }); mobileUpdateDetailNavigation.start(); @@ -1422,6 +1425,7 @@ history:qs('#review-history-workspace'), }, summaryComposer:qs('#review-summary'), + onSectionChange:(section, options) => workRoute.section(section, options), prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, }); mobileReviewDetailNavigation.start(); @@ -1665,6 +1669,25 @@ workRoute.open(routedWorkItem(item), options); } + function navigateWorkSection(kind, section) { + if (['issue', 'filed'].includes(kind)) { + mobileIssueDetailNavigation.navigate(section, { focus:false }); + } else if (kind === 'pull') { + mobilePullDetailNavigation.navigate(section, { focus:false }); + } else if (kind === 'update') { + mobileUpdateDetailNavigation.navigate(section, { focus:false }); + } else if (kind === 'review') { + mobileReviewDetailNavigation.navigate(section, { focus:false }); + } + } + + async function openRoutedWorkSection(item) { + if (item.kind === 'update') await notificationReader.open(item, lastMyWork); + else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger); + else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger); + else if (item.kind === 'pull') await openPullSheet(item, pullTrigger); + } + const workRoute = createWorkRoute.createController({ location: window.location, history: window.history, @@ -1682,14 +1705,17 @@ qs('#retry-work-route').hidden = true; qs('#my-work-action-status').textContent = 'Loading shared work item…'; }, - onOpen: item => { + onOpen: async item => { qs('#retry-work-route').hidden = true; qs('#my-work-action-status').textContent = ''; closeOpenWorkSheets(); - if (item.kind === 'update') notificationReader.open(item, lastMyWork); - else if (item.kind === 'review') openReviewSheet(item, reviewTrigger); - else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger); - else if (item.kind === 'pull') openPullSheet(item, pullTrigger); + await openRoutedWorkSection(item); + const route = createWorkRoute.parse(window.location.hash); + if (route?.section === item.section) navigateWorkSection(item.kind, item.section); + }, + onSection: (section, options) => { + const route = createWorkRoute.parse(window.location.hash); + if (options.restore) navigateWorkSection(route?.kind, section); }, onQueue: openWorkQueueRoute, onClose: () => { diff --git a/frontend/mobile-issue-detail-nav.js b/frontend/mobile-issue-detail-nav.js index deba34e..a36ceca 100644 --- a/frontend/mobile-issue-detail-nav.js +++ b/frontend/mobile-issue-detail-nav.js @@ -14,7 +14,7 @@ function createMobileIssueDetailNavigation(options) { }); } - function navigate(name) { + function navigate(name, navigationOptions = {}) { const target = targets[name]; if (!target) return false; const prepare = options.beforeNavigate && options.beforeNavigate[name]; @@ -24,7 +24,7 @@ function createMobileIssueDetailNavigation(options) { block: 'start', behavior: prefersReducedMotion() ? 'auto' : 'smooth', }); - if (name === 'reply') target.focus(); + if (name === 'reply' && navigationOptions.focus !== false) target.focus(); select(name); return true; } @@ -36,6 +36,7 @@ function createMobileIssueDetailNavigation(options) { const listener = event => { event.preventDefault(); navigate(name); + if (options.onSectionChange) options.onSectionChange(name, { replace:false }); }; listeners.set(button, listener); button.addEventListener('click', listener); @@ -55,7 +56,11 @@ function createMobileIssueDetailNavigation(options) { const visible = entries .filter(entry => entry.isIntersecting && targetNames.has(entry.target)) .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0]; - if (visible) select(targetNames.get(visible.target)); + if (visible) { + const name = targetNames.get(visible.target); + select(name); + if (options.onSectionChange) options.onSectionChange(name, { replace:true }); + } }, Array.from(targetNames.keys()).filter(Boolean)); }, stop() { diff --git a/frontend/mobile-review-detail-nav.js b/frontend/mobile-review-detail-nav.js index 60c83b2..50fa530 100644 --- a/frontend/mobile-review-detail-nav.js +++ b/frontend/mobile-review-detail-nav.js @@ -14,14 +14,14 @@ function createMobileReviewDetailNavigation(options) { }); } - function navigate(name) { + function navigate(name, navigationOptions = {}) { const target = targets[name]; if (!target) return false; target.scrollIntoView({ block:'start', behavior:prefersReducedMotion() ? 'auto' : 'smooth', }); - if (name === 'feedback' && options.summaryComposer) { + if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) { options.summaryComposer.focus({ preventScroll:true }); } select(name); @@ -39,6 +39,7 @@ function createMobileReviewDetailNavigation(options) { const listener = event => { event.preventDefault(); navigate(name); + if (options.onSectionChange) options.onSectionChange(name, { replace:false }); }; listeners.set(button, listener); button.addEventListener('click', listener); @@ -58,7 +59,11 @@ function createMobileReviewDetailNavigation(options) { const visible = entries .filter(entry => entry.isIntersecting && targetNames.has(entry.target)) .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0]; - if (visible) select(targetNames.get(visible.target)); + if (visible) { + const name = targetNames.get(visible.target); + select(name); + if (options.onSectionChange) options.onSectionChange(name, { replace:true }); + } }, Array.from(targetNames.keys()).filter(Boolean), options.root || null); }, stop() { diff --git a/frontend/mobile-update-detail-nav.js b/frontend/mobile-update-detail-nav.js index 70af14d..f0dde91 100644 --- a/frontend/mobile-update-detail-nav.js +++ b/frontend/mobile-update-detail-nav.js @@ -14,7 +14,7 @@ function createMobileUpdateDetailNavigation(options) { }); } - function navigate(name) { + function navigate(name, navigationOptions = {}) { if (name === 'activity') { if (options.jumpToNewActivity) options.jumpToNewActivity(); select(name); @@ -27,7 +27,7 @@ function createMobileUpdateDetailNavigation(options) { block:'start', behavior:prefersReducedMotion() ? 'auto' : 'smooth', }); - if (name === 'reply' && options.replyComposer) { + if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) { options.replyComposer.focus({ preventScroll:true }); } select(name); @@ -46,6 +46,7 @@ function createMobileUpdateDetailNavigation(options) { const listener = event => { event.preventDefault(); navigate(name); + if (options.onSectionChange) options.onSectionChange(name, { replace:false }); }; listeners.set(button, listener); button.addEventListener('click', listener); @@ -65,7 +66,11 @@ function createMobileUpdateDetailNavigation(options) { const visible = entries .filter(entry => entry.isIntersecting && targetNames.has(entry.target)) .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0]; - if (visible) select(targetNames.get(visible.target)); + if (visible) { + const name = targetNames.get(visible.target); + select(name); + if (options.onSectionChange) options.onSectionChange(name, { replace:true }); + } }, Array.from(targetNames.keys()).filter(Boolean)); }, stop() { diff --git a/frontend/work-route.js b/frontend/work-route.js index 05200cb..957cd5a 100644 --- a/frontend/work-route.js +++ b/frontend/work-route.js @@ -7,6 +7,13 @@ const repositoryPart = /^[A-Za-z0-9_.-]+$/; const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft']; + const sections = { + issue: ['overview', 'conversation', 'reply', 'actions'], + filed: ['overview', 'conversation', 'reply', 'actions'], + pull: ['overview', 'conversation', 'reply', 'review'], + review: ['overview', 'files', 'feedback', 'history'], + update: ['activity', 'conversation', 'context', 'reply'], + }; function positiveInteger(value) { const number = Number(value); @@ -23,27 +30,37 @@ if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) { return { kind: 'queue', filter: 'agenda', action: 'protect-today' }; } - if (parts[2] === 'update' && parts.length === 4) { + if (parts[2] === 'update' && [4, 5].includes(parts.length)) { const notificationId = positiveInteger(parts[3]); - return notificationId ? { kind: 'update', notification_id: notificationId } : null; + const section = parts[4]; + if (!notificationId || (section && !sections.update.includes(section))) return null; + return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) }; } - if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null; + if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null; if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null; const number = positiveInteger(parts[5]); - return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null; + const section = parts[6]; + if (!number || (section && !sections[parts[2]].includes(section))) return null; + return { + kind: parts[2], repository: parts[3] + '/' + parts[4], number, + ...(section ? { section } : {}), + }; } function serialize(item) { if (item?.kind === 'update') { const notificationId = positiveInteger(item.notification_id); - return notificationId ? '#/my-work/update/' + notificationId : ''; + if (!notificationId || (item.section && !sections.update.includes(item.section))) return ''; + return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : ''); } const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind; if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return ''; const repository = String(item.repository || '').split('/'); const number = positiveInteger(item.number); if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return ''; - return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number; + if (item.section && !sections[kind].includes(item.section)) return ''; + return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number + + (item.section ? '/' + item.section : ''); } function sameRoute(item, route) { @@ -59,7 +76,7 @@ function createController({ location, history, eventTarget, onOpen, onClose, onInvalid, - onQueue = function () {}, + onQueue = function () {}, onSection = function () {}, resolve, onResolving = function () {}, onError = function () {}, }) { let items = []; @@ -86,7 +103,7 @@ return; } active = fragment; - onOpen({ ...item, kind: route.kind }); + onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) }); }).catch(error => { if (request !== resolution || String(location.hash || '') !== fragment) return; resolving = ''; @@ -128,10 +145,16 @@ return; } if (active === fragment) return; + const activeRoute = parse(active); + if (activeRoute && sameRoute(item, activeRoute)) { + active = fragment; + onSection(route.section || sections[route.kind][0], { restore: true }); + return; + } resolution += 1; resolving = ''; active = fragment; - onOpen({ ...item, kind: route.kind }); + onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) }); } return { @@ -156,6 +179,17 @@ onOpen(item); return true; }, + section(name, options = {}) { + const route = parse(location.hash); + if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false; + if ((route.section || sections[route.kind][0]) === name) return true; + const fragment = serialize({ ...route, section: name }); + const method = options.replace ? 'replaceState' : 'pushState'; + history[method]({ workRoute: fragment }, '', fragment); + active = fragment; + onSection(name, { restore: false }); + return true; + }, queue(filter) { if (!queueFilters.includes(filter)) return false; const name = filter + (['update', 'draft'].includes(filter) ? 's' : ''); diff --git a/tests/test_mobile_issue_detail_navigation.py b/tests/test_mobile_issue_detail_navigation.py index 61e6066..57cf4e1 100644 --- a/tests/test_mobile_issue_detail_navigation.py +++ b/tests/test_mobile_issue_detail_navigation.py @@ -116,6 +116,45 @@ process.stdout.write(JSON.stringify({{current,disconnected}})); } +def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore(): + script = f""" +const createNavigation = require({json.dumps(str(CONTROLLER))}); +class FakeElement {{ + constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }} + addEventListener(name, callback) {{ this.listeners[name]=callback; }} + removeEventListener() {{}} + click() {{ this.listeners.click({{preventDefault() {{}}}}); }} + setAttribute(name, value) {{ this.attributes[name]=value; }} + removeAttribute(name) {{ delete this.attributes[name]; }} + scrollIntoView() {{}} + focus() {{ this.focuses += 1; }} +}} +const names=['overview','conversation','reply','actions']; +const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); +const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)])); +const changes=[]; +let observer; +const navigation=createNavigation({{ + buttons, targets, + onSectionChange:(name, options) => changes.push([name, options.replace]), + observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }}, +}}); +navigation.start(); +buttons.reply.click(); +observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]); +navigation.navigate('reply', {{focus:false}}); +process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "changes": [["reply", False], ["actions", True]], + "replyFocuses": 1, + "current": "location", + } + + def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() diff --git a/tests/test_mobile_review_detail_navigation.py b/tests/test_mobile_review_detail_navigation.py index d1a065c..03739f4 100644 --- a/tests/test_mobile_review_detail_navigation.py +++ b/tests/test_mobile_review_detail_navigation.py @@ -116,6 +116,38 @@ process.stdout.write(JSON.stringify({{current,rootMatches:observedRoot === panel } +def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore(): + script = f""" +const createNavigation=require({json.dumps(str(CONTROLLER))}); +const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0, + addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}}, + setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }}, + scrollIntoView() {{}}, focus() {{ this.focuses += 1; }}, +}}); +const names=['overview','files','feedback','history']; +const buttons=Object.fromEntries(names.map(name => [name,element(name)])); +const targets=Object.fromEntries(names.map(name => [name,element(name)])); +const composer=element('composer'); const changes=[]; let observer; +const navigation=createNavigation({{buttons,targets,summaryComposer:composer, + onSectionChange:(name,options)=>changes.push([name,options.replace]), + observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }}, +}}); +navigation.start(); +buttons.feedback.listeners.click({{preventDefault() {{}}}}); +observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]); +navigation.navigate('feedback',{{focus:false}}); +process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.attributes['aria-current']}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "changes": [["feedback", False], ["history", True]], + "focuses": 1, + "current": "location", + } + + def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() diff --git a/tests/test_mobile_update_detail_navigation.py b/tests/test_mobile_update_detail_navigation.py index 3bab92d..3a09969 100644 --- a/tests/test_mobile_update_detail_navigation.py +++ b/tests/test_mobile_update_detail_navigation.py @@ -123,6 +123,38 @@ process.stdout.write(JSON.stringify({{current,disconnected}})); } +def test_update_navigation_routes_taps_and_scrolls_without_focusing_on_restore(): + script = f""" +const createNavigation=require({json.dumps(str(CONTROLLER))}); +const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,open:false, + addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}}, + setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }}, + scrollIntoView() {{}}, focus() {{ this.focuses += 1; }}, +}}); +const names=['activity','conversation','context','reply']; +const buttons=Object.fromEntries(names.map(name => [name,element(name)])); +const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,element(name)])); +const composer=element('composer'); const changes=[]; let observer; +const navigation=createNavigation({{buttons,targets,replyComposer:composer,jumpToNewActivity() {{}}, + onSectionChange:(name,options)=>changes.push([name,options.replace]), + observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }}, +}}); +navigation.start(); +buttons.reply.listeners.click({{preventDefault() {{}}}}); +observer([{{target:targets.context,isIntersecting:true,intersectionRatio:1}}]); +navigation.navigate('reply',{{focus:false}}); +process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.reply.attributes['aria-current']}})); +""" + result = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "changes": [["reply", False], ["context", True]], + "focuses": 1, + "current": "location", + } + + def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail(): html = (FRONTEND / "index.html").read_text() css = (FRONTEND / "dashboard.css").read_text() diff --git a/tests/test_my_work.py b/tests/test_my_work.py index cbcee8a..f6df2ae 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1477,6 +1477,68 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}})); assert output["hash"] == "#/my-work/review/stackchain/dashboard/10" +def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item(): + script = f""" +const routes = require({json.dumps(str(WORK_ROUTE))}); +const listeners = {{}}; +const location = {{hash:'#/my-work/issue/stackchain/api/17'}}; +const calls = []; +const stack = [location.hash]; +let cursor = 0; +const history = {{ + pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }}, + replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }}, + back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }}, +}}; +const controller = routes.createController({{ + location, history, + eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}}, + onOpen: item => calls.push(['open', item.number, item.section || null]), + onSection: (section, options) => calls.push(['section', section, options.restore]), + onClose() {{}}, onInvalid() {{}}, +}}); +controller.start(); +controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]); +controller.section('reply'); +controller.section('actions', {{replace:true}}); +history.back(); +process.stdout.write(JSON.stringify({{ + calls, + hash:location.hash, + valid:[ + routes.parse('#/my-work/issue/stackchain/api/17/reply'), + routes.parse('#/my-work/update/42/context'), + ], + invalid:[ + routes.parse('#/my-work/issue/stackchain/api/17/files'), + routes.parse('#/my-work/update/42/overview'), + routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'), + ], +}})); +""" + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "calls": [ + ["open", 17, None], + ["push", "#/my-work/issue/stackchain/api/17/reply"], + ["section", "reply", False], + ["replace", "#/my-work/issue/stackchain/api/17/actions"], + ["section", "actions", False], + ["section", "overview", True], + ], + "hash": "#/my-work/issue/stackchain/api/17", + "valid": [ + {"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"}, + {"kind": "update", "notification_id": 42, "section": "context"}, + ], + "invalid": [None, None, None], + } + + def test_updates_inbox_route_survives_hydration_and_detail_back_navigation(): script = f""" const routes = require({json.dumps(str(WORK_ROUTE))}); @@ -1690,7 +1752,7 @@ const calls = []; async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities(): html = await dashboard() - assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html + assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html assert "const readOnly = issueController.readOnly(item);" in html assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html assert "#issue-sheet.read-only .issue-comment-composer" not in html @@ -1730,6 +1792,24 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share assert "openDeliveryReceiptRoute" not in html +@pytest.mark.anyio +async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation(): + html = await dashboard() + + assert html.count( + "onSectionChange:(section, options) => workRoute.section(section, options)" + ) == 4 + assert "function navigateWorkSection(kind, section)" in html + assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html + assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html + assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html + assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html + assert "onSection: (section, options) => {" in html + assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html + assert "await openRoutedWorkSection(item);" in html + assert "navigateWorkSection(item.kind, item.section);" in html + + def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity(): payload = { "user": {"login": "timmy"}, -- 2.43.0