From 88ed1ed54548bd7b00b941bbc07c1ccf82440ae2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 08:42:54 -0400 Subject: [PATCH] TASK-conversation-console-workflow settle Fleet deep links after initial render --- docs/TEST_INTEGRITY.md | 10 ++-- scripts/test-dashboard-demo.mjs | 1 + site/console.js | 60 ++++++++++++++++----- test/fleet/console-view-navigation.test.mjs | 48 +++++++++++++++++ 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index bb6f2b2..0e97c64 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2599,9 +2599,9 @@ test_integrity_note: when: "The reader opens either direct link and uses browser Back and Forward" then: "Exactly the matching view is selected and the actual secondary target is focused inside the viewport" - id: SCN-console-operational-deeplink-fleet - given: "Operations, resources, schedules, providers, decisions, context, and manager-wakes are grouped in the Fleet workspace" - when: "The reader loads #manager-wakes directly, follows its existing attention link, or returns to it through Back and Forward" - then: "Fleet is selected, the Operations disclosure opens, manager-wakes receives focus inside the viewport, and the entire Operations section is hidden on unrelated views" + given: "Operations, resources, schedules, providers, decisions, context, and manager-wakes are grouped in the Fleet workspace at 1280x900 and 390x844" + when: "The reader loads #manager-wakes directly, follows its existing attention link, or returns to it through Back and Forward, then later navigates, scrolls, and refreshes" + then: "After the first deterministic data/layout application Fleet is selected, the Operations disclosure opens, manager-wakes receives focus fully inside the viewport, the later refresh preserves the reader's view and scroll, and Operations is hidden on unrelated views" - id: SCN-console-view-drafts given: "A task draft and an owner approval preview are present" when: "The reader changes views and refreshes the sample Console" @@ -2614,12 +2614,12 @@ test_integrity_note: - "An unchanged current-revision preview, confirmation boundary, and draft retained after refresh" - "A regenerated preview token and displayed revision bound to N+1" - "Exact Overview and contextual view titles, plus focused in-viewport secondary targets across direct loads and browser history" - - "The manager-wakes route selects Fleet and opens/focuses its nested target across direct navigation and browser history; operations stay hidden outside Fleet" + - "The manager-wakes route selects Fleet and opens/focuses its nested target fully inside desktop and mobile viewports after first render and across browser history; later refresh preserves the user's view and scroll; operations stay hidden outside Fleet" - "Visible recorded-evidence limitation beside owner briefing content while exact provenance remains compact and collapsed" - "No browser requests to live project APIs and no changes to tracked screenshot expectations" determinism_controls: - "The existing isolated sample Console, fixed Playwright clock, and fixed desktop/mobile viewports" - - "Manager-wakes routing uses the existing isolated sample Console and browser fragment history without changing project state" + - "Manager-wakes routing uses the existing isolated sample Console, a deterministic first-generation completion marker, and browser fragment history without changing project state" - "Newer evidence is introduced through the sample's supported preview/confirm API before an explicit refresh" - "The browser test advances only the isolated sample API; it never submits the priority form or mutates a live project" - "No network, live project mutation, retry, sleep, timeout, golden update, or direct fixture-source change" diff --git a/scripts/test-dashboard-demo.mjs b/scripts/test-dashboard-demo.mjs index 556b276..3d7f71d 100644 --- a/scripts/test-dashboard-demo.mjs +++ b/scripts/test-dashboard-demo.mjs @@ -231,6 +231,7 @@ try { await page.locator('#owner-briefing .briefing-detail').first().locator('summary').click(); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, 'expanded briefing evidence must fit mobile width'); await page.locator('#reset-demo').click(); + await selectView(page, 'flow-watch'); await page.locator('#approval-request-count').filter({ hasText: '1 open' }).waitFor(); assert.equal(await page.locator('.priority-urgent').count(), 0); assert.equal(await page.locator('.agent-row').count(), 6); diff --git a/site/console.js b/site/console.js index dc1fff7..9cab7d5 100644 --- a/site/console.js +++ b/site/console.js @@ -18,6 +18,7 @@ function installConsoleViewRouting() { if (!nav || !sections.length || !links.length) return { sync: () => 'overview' }; let lastHash = null; + const initialHash = globalThis.location.hash; const decodeHash = (hash) => { try { return decodeURIComponent(String(hash ?? '').replace(/^#/, '')); } catch { return ''; } }; @@ -44,6 +45,29 @@ function installConsoleViewRouting() { } } + function positionNestedTarget(target, targetId, view) { + const targetView = target?.closest('[data-console-view]')?.dataset.consoleView; + if (!target || targetId === view || targetView !== view) return; + for (let ancestor = target; ancestor; ancestor = ancestor.parentElement) { + if (ancestor instanceof HTMLDetailsElement) ancestor.open = true; + } + accountForStickyNavigation(target); + if (!target.hasAttribute('tabindex') && !target.matches('a[href], button, input, select, textarea, summary')) { + target.tabIndex = -1; + } + target.scrollIntoView?.({ block: 'start', behavior: 'instant' }); + target.focus?.({ preventScroll: true }); + } + + const initialFragment = decodeHash(initialHash); + let initialPositionPending = Boolean(initialFragment && !consoleViewDefinitions[initialFragment] + && document.getElementById(initialFragment)); + const abandonInitialPosition = () => { initialPositionPending = false; }; + const onNavigation = () => { + abandonInitialPosition(); + sync({ focus: true }); + }; + function sync({ focus = false, force = false } = {}) { const hash = globalThis.location.hash || '#overview'; if (!force && hash === lastHash) return resolveView(hash); @@ -68,15 +92,7 @@ function installConsoleViewRouting() { const target = document.getElementById(targetId); const targetView = target?.closest('[data-console-view]')?.dataset.consoleView; if (target && targetId !== view && targetView === view) { - for (let ancestor = target; ancestor; ancestor = ancestor.parentElement) { - if (ancestor instanceof HTMLDetailsElement) ancestor.open = true; - } - accountForStickyNavigation(target); - if (!target.hasAttribute('tabindex') && !target.matches('a[href], button, input, select, textarea, summary')) { - target.tabIndex = -1; - } - target.scrollIntoView?.({ block: 'start', behavior: 'instant' }); - target.focus?.({ preventScroll: true }); + positionNestedTarget(target, targetId, view); } else { const labelledBy = visible[0]?.getAttribute('aria-labelledby'); const heading = (labelledBy && document.getElementById(labelledBy)) @@ -96,11 +112,28 @@ function installConsoleViewRouting() { return view; } - globalThis.addEventListener('hashchange', () => sync({ focus: true })); - globalThis.addEventListener('popstate', () => sync({ focus: true })); - const initialFragment = decodeHash(globalThis.location.hash); + globalThis.addEventListener('hashchange', onNavigation); + globalThis.addEventListener('popstate', onNavigation); + globalThis.addEventListener('wheel', abandonInitialPosition, { passive: true }); + globalThis.addEventListener('touchstart', abandonInitialPosition, { passive: true }); + globalThis.addEventListener('pointerdown', abandonInitialPosition, { passive: true }); + globalThis.addEventListener('keydown', (event) => { + if (['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Home', 'End', ' '].includes(event.key)) { + abandonInitialPosition(); + } + }); sync({ focus: Boolean(initialFragment && !consoleViewDefinitions[initialFragment]) }); - return { sync }; + return { + sync, + afterInitialLayout() { + if (!initialPositionPending) return; + initialPositionPending = false; + if (globalThis.location.hash !== initialHash) return; + const targetId = decodeHash(initialHash); + const view = resolveView(initialHash); + positionNestedTarget(document.getElementById(targetId), targetId, view); + }, + }; } const $ = (selector) => document.querySelector(selector); @@ -1118,6 +1151,7 @@ const refreshScheduler = globalThis.TorchLiveRefresh.createScheduler({ const status = $('#live-refresh-status'); status.dataset.generation = String(++appliedRefreshes); status.textContent = `${liveUpdatesPaused ? 'Automatic updates paused.' : 'Updates every 15 seconds while visible.'}${heldPanels ? ' Edited forms and active previews retained; their panels may show older state.' : ''}`; + if (appliedRefreshes === 1) consoleViewRouter.afterInitialLayout(); }, onError: (caught) => { const error = $('#console-error'); diff --git a/test/fleet/console-view-navigation.test.mjs b/test/fleet/console-view-navigation.test.mjs index 3e95116..db82709 100644 --- a/test/fleet/console-view-navigation.test.mjs +++ b/test/fleet/console-view-navigation.test.mjs @@ -105,6 +105,54 @@ test('SCN-console-secondary-deeplink-focus-and-history: direct fragments and his await assertTarget('approval-request-list', 'flow-watch', 'Flow watch'); }); +test('SCN-console-operational-deeplink-layout-history-and-refresh: Fleet deep links settle after first render without stealing later navigation', async (t) => { + for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) { + const { page } = await openDemo(t, viewport); + const base = `http://127.0.0.1:${new URL(page.url()).port}/console?demo=1`; + const assertManagerWakes = async () => { + await assertSelectedView(page, 'fleet', 'Fleet'); + const state = await page.locator('#manager-wakes').evaluate((target) => { + const bounds = target.getBoundingClientRect(); + return { + activeId: document.activeElement?.id, + operationsOpen: target.closest('#fleet-operations')?.open, + top: bounds.top, + bottom: bounds.bottom, + height: innerHeight, + }; + }); + assert.equal(state.operationsOpen, true); + assert.equal(state.activeId, 'manager-wakes'); + assert.ok(state.top >= 0 && state.bottom <= state.height, + `manager-wakes must be fully inside ${viewport.width}x${viewport.height}: ${JSON.stringify(state)}`); + }; + + await page.goto(`${base}#manager-wakes`); + await page.locator('#live-refresh-status[data-generation="1"]').waitFor(); + await assertManagerWakes(); + + await page.goto(`${base}#overview`); + await page.locator('#live-refresh-status[data-generation="1"]').waitFor(); + await page.locator('#attention-list a[href="#manager-wakes"]').click(); + await assertManagerWakes(); + await page.goBack(); + await assertSelectedView(page, 'overview', 'Overview'); + await page.goForward(); + await assertManagerWakes(); + + await page.locator('.console-rail a[href="#work"]').click(); + await page.mouse.wheel(0, 180); + const beforeRefresh = await page.evaluate(() => scrollY); + await page.locator('#refresh-console').click(); + await page.locator('#live-refresh-status[data-generation="2"]').waitFor(); + assert.equal(new URL(page.url()).hash, '#work'); + await assertSelectedView(page, 'work', 'Work'); + assert.notEqual(await page.evaluate(() => document.activeElement?.id), 'manager-wakes'); + assert.equal(await page.evaluate(() => scrollY), beforeRefresh, + 'a later refresh must retain the user’s scrolled position'); + } +}); + test('SCN-console-priority-preview-invalidates-on-newer-evidence: refresh preserves editable drafts and rejects the old preview', async (t) => { const { page } = await openDemo(t, { width: 1280, height: 900 }); const form = page.locator('.priority-change-form[data-task-id="API-08"]');