From 68db96f189f91de319f2e258758529fc6e208904 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 17:10:35 +0800 Subject: [PATCH 1/4] test(desktop): move renderer checks out of Electron Generated-by: OpenAI Codex --- apps/desktop/e2e-budget.json | 12 +- apps/desktop/e2e/session-workbar.spec.ts | 22 +- apps/desktop/e2e/settings.spec.ts | 161 ++------------ .../e2e/transcript-scroll-cost.spec.ts | 199 ++---------------- apps/desktop/renderer-architecture.json | 3 +- apps/desktop/src/preload/preload.ts | 7 +- .../src/renderer/app-shell-overlays.tsx | 70 +++--- apps/desktop/stories/app-shell.stories.tsx | 105 ++++++++- .../settings/settings-pages.stories.tsx | 51 ++++- 9 files changed, 216 insertions(+), 414 deletions(-) diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index fa464f77f1..3d49a2a75d 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -42,12 +42,12 @@ "electron": "SQLite admission crosses preload and main, survives both renderer reload and complete Electron restart, and cached history must render when the live IPC endpoint is unavailable" }, "session-workbar.spec.ts": { - "tests": 6, - "electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, a first send that has to reach the Host, and per-Session collapse persisted across a renderer reload; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host" + "tests": 5, + "electron": "Git changes re-read on native window focus; terminal PTY ownership changes across Sessions; Side Chat owns a Host fork lifecycle; first send reaches the Host; the composer usage entry opens a workbar whose per-Session visibility survives renderer reload." }, "settings.spec.ts": { - "tests": 5, - "electron": "Code Mode persistence crosses renderer/preload/main/Host settings IPC; the preload makaE2eLatch holds the settings chunk mid-load, and the rename it commits is a Host write; the workbar-chrome test rides along on that window and would move to a story the day app-shell.tsx's settings wiring has one" + "tests": 2, + "electron": "Code Mode reads back Host settings through IPC; opening Settings commits a pending title rename to the Host, with workbar hide/restore asserted in that same Session." }, "sidebar-project-reload.spec.ts": { "tests": 1, @@ -66,8 +66,8 @@ "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, "transcript-scroll-cost.spec.ts": { - "tests": 4, - "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping, and reader displacement only exists against a real layout" + "tests": 1, + "electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Chromium containment and fixture motion run in the TranscriptRenderCost story." }, "workhub-layout.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index c7c2124b0b..8e9b909230 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -53,22 +53,6 @@ async function createSession(page: Page, prompt: string) { return { composer, sessionId: sessionId!, sidebar }; } -test('the composer usage action opens Task trace in the right workbar', async ({ - accessibilityNarrativeWindow: page, -}) => { - const action = page.getByRole('button', { name: '打开用量追踪' }); - await expect(action).toBeVisible(); - - await action.click(); - - const rightPanel = page.locator( - '.maka-session-workbar-panel[data-overlay][data-placement="right"]', - ); - await expect( - rightPanel.locator('[data-maka-contract="session-inspector"]'), - ).toBeVisible(); -}); - test('right workbar visibility belongs to each Session and survives reload', async ({ window: page, }) => { @@ -79,8 +63,10 @@ test('right workbar visibility belongs to each Session and survives reload', asy .getByRole('list', { name: '打开工具' }) .getByRole('button', { name: /变更.*查看当前 Git 工作区变化/ }) .click(); - await page.getByRole('button', { name: '打开或关闭工作栏的面' }).click(); - await page.getByRole('menu').getByRole('menuitem', { name: '追踪', exact: true }).click(); + await page.getByRole('button', { name: '打开用量追踪' }).click(); + await expect(page.locator( + '.maka-session-workbar-panel[data-overlay][data-placement="right"] [data-maka-contract="session-inspector"]', + )).toBeVisible(); await expect(panel).toBeVisible(); await first.sidebar.getByRole('button', { name: '新任务', exact: true }).click(); const second = await createSession(page, 'second workbar owner'); diff --git a/apps/desktop/e2e/settings.spec.ts b/apps/desktop/e2e/settings.spec.ts index 412e035844..67f04c1fae 100644 --- a/apps/desktop/e2e/settings.spec.ts +++ b/apps/desktop/e2e/settings.spec.ts @@ -19,13 +19,6 @@ import { awaitSendReady, COMPOSER_INPUT, ensureSidebarExpanded, expect, test } from './fixtures'; -interface SettingsChunkLatchWindow extends Window { - makaE2eLatch?: { - arm(key: 'settings.chunk'): void; - release(key: 'settings.chunk'): void; - }; -} - test('Code Mode persists as a global setting after reopening settings', async ({ window: page }, testInfo) => { await ensureSidebarExpanded(page); await page.getByRole('button', { name: '设置' }).click(); @@ -48,84 +41,12 @@ test('Code Mode persists as a global setting after reopening settings', async ({ await expect.poll(() => page.evaluate(async () => (await window.maka.settings.get()).chatDefaults.codeModeEnabled === true)).toBe(false); }); -test('Settings loading surface owns unmodified Escape', async ({ window: page }) => { - const latchInstalled = await page.evaluate(() => { - const e2eLatch = (window as unknown as SettingsChunkLatchWindow).makaE2eLatch; - e2eLatch?.arm('settings.chunk'); - return e2eLatch !== undefined; - }); - expect(latchInstalled, 'the preload E2E latch is installed').toBe(true); - - try { - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: '设置' }).click(); - - const loadingSurface = page.locator('.maka-lazy-fallback'); - await expect(loadingSurface).toBeVisible(); - - for (const modifier of ['ctrlKey', 'metaKey', 'altKey'] as const) { - const wasNotPrevented = await page.evaluate((key) => - window.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Escape', - [key]: true, - bubbles: true, - cancelable: true, - }), - ), modifier); - expect(wasNotPrevented).toBe(true); - await expect(loadingSurface).toBeVisible(); - } - - const wasNotPrevented = await page.evaluate(() => - window.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - cancelable: true, - }), - ), - ); - expect(wasNotPrevented).toBe(false); - await expect(page.locator('.settingsModal')).toHaveCount(0); - } finally { - await page.evaluate(() => - (window as unknown as SettingsChunkLatchWindow).makaE2eLatch?.release( - 'settings.chunk', - ), - ); - } -}); - test('opening settings commits an active titlebar rename', async ({ window: page }) => { const composer = page.locator(COMPOSER_INPUT); await composer.fill('create a session for settings rename'); await awaitSendReady(page); await composer.press('Enter'); - const identity = page.locator('[data-maka-contract="titlebar-identity"]'); - await expect(identity).toBeVisible(); - await page.getByRole('button', { name: '展开侧边栏' }).click(); - await identity.getByRole('button', { name: /重命名任务/ }).click(); - await page.getByRole('textbox', { name: '重命名任务' }).fill('renamed before settings'); - - // Programmatic activation preserves input focus, matching the macOS - // application-menu command that opens Settings before Chromium can blur it. - await page.getByRole('button', { name: '设置' }).evaluate((button) => button.click()); - await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); - await page.keyboard.press('Escape'); - - await expect(identity).toContainText('renamed before settings'); -}); - -test('settings hides expanded workbar chrome and restores it on close', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill('create a session with an expanded workbar'); - await awaitSendReady(page); - await composer.press('Enter'); - await page.getByRole('button', { name: '展开任务工作栏' }).click(); const workbar = page.locator('.maka-session-workbar[data-placement="right"]'); const workbarToolbar = workbar.getByRole('toolbar', { name: '任务工作栏标签' }); @@ -140,79 +61,23 @@ test('settings hides expanded workbar chrome and restores it on close', async ({ const openFaceTab = workbarToolbar.getByRole('tab', { name: '变更' }); await expect(openFaceTab).toBeVisible(); - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: '设置' }).click(); + const identity = page.locator('[data-maka-contract="titlebar-identity"]'); + await expect(identity).toBeVisible(); + await page.getByRole('button', { name: '展开侧边栏' }).click(); + await identity.getByRole('button', { name: /重命名任务/ }).click(); + await page.getByRole('textbox', { name: '重命名任务' }).fill('renamed before settings'); + + // Programmatic activation preserves input focus, matching the macOS + // application-menu command that opens Settings before Chromium can blur it. + await page.getByRole('button', { name: '设置' }).evaluate((button) => button.click()); await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); await expect(workbar).not.toBeVisible(); - await page.keyboard.press('Escape'); await expect(workbarToolbar).toBeVisible(); await expect(openFaceTab).toBeVisible(); -}); - -test('reopening settings keeps the last-ready General page stable while refreshing', async ({ - window: page, -}) => { - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: '设置' }).click(); - const settings = page.locator('.settingsSurface'); - await page.getByRole('button', { name: '通用', exact: true }).click(); - await expect(settings.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled(); - await expect(settings.getByRole('button', { name: '默认模型' })).toBeEnabled(); - - await page.keyboard.press('Escape'); - await expect(settings).toHaveCount(0); - await expect(page.getByRole('button', { name: '设置' })).toBeVisible(); - await page.evaluate(() => { - const state = { - sawLoadingWarning: false, - sawGeneralWithoutReadyHostControls: false, - }; - const inspect = () => { - const surface = document.querySelector('.settingsSurface'); - const main = surface?.querySelector('main, [role="main"]'); - if (!surface || !main) return; - state.sawLoadingWarning ||= Array.from( - surface.querySelectorAll('[role="alert"]'), - ).some((banner) => banner.textContent?.includes('正在加载设置') === true); - const defaultModelReady = Array.from(main.querySelectorAll('*')).some( - (element) => - element.children.length === 0 && - element.textContent?.trim() === '默认模型' && - Boolean(element.closest('.astryx-item')?.querySelector('button')), - ); - state.sawGeneralWithoutReadyHostControls ||= - main.querySelector('textarea') === null || !defaultModelReady; - }; - const observer = new MutationObserver(inspect); - observer.observe(document.body, { childList: true, subtree: true, characterData: true }); - Object.assign(window, { - __makaSettingsReopenProbe: { - finish() { - inspect(); - observer.disconnect(); - return state; - }, - }, - }); - }); - - await page.getByRole('button', { name: '设置' }).click(); - await expect(settings.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled(); - await expect(settings.getByRole('button', { name: '默认模型' })).toBeEnabled(); - - const probe = await page.evaluate(() => { - const target = window as typeof window & { - __makaSettingsReopenProbe: { - finish(): { - sawLoadingWarning: boolean; - sawGeneralWithoutReadyHostControls: boolean; - }; - }; - }; - return target.__makaSettingsReopenProbe.finish(); - }); - expect(probe.sawLoadingWarning).toBe(false); - expect(probe.sawGeneralWithoutReadyHostControls).toBe(false); + await expect(identity).toContainText('renamed before settings'); + await expect.poll(() => page.evaluate(async () => + (await window.maka.sessions.list()).some((session) => session.name === 'renamed before settings'), + )).toBe(true); }); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 388ad4546b..4b5efff725 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -62,12 +62,6 @@ const BOUNDARY_DISPLACEMENT_MAX_PX = 40; declare global { interface Window { - __makaTranscriptCost?: { - transitionRuns: number; - animationStarts: number; - skipped: WeakSet; - skippedCount: number; - }; __makaTranscriptDisplacement?: { boundaries: TranscriptBoundary[]; record(on: boolean): void; @@ -123,47 +117,6 @@ async function wheel( )); } -/** - * Count every transition and animation the page starts, and track which Turns - * the browser is currently skipping. - * - * `contentvisibilityautostatechange` rather than - * `checkVisibility({ contentVisibilityAuto: true })`: the flag that method - * reads is updated during rendering, so a synchronous call right after a - * scroll reports every Turn visible even when the browser is skipping most of - * them. Measured on this fixture, the method returned 0 skipped Turns in every - * position the event reported between 1 and 8. - */ -async function observe(page: Page): Promise { - await page.evaluate(() => { - const state = { - transitionRuns: 0, - animationStarts: 0, - skipped: new WeakSet(), - skippedCount: 0, - }; - window.__makaTranscriptCost = state; - document.addEventListener('transitionrun', () => { state.transitionRuns += 1; }, true); - document.addEventListener('animationstart', () => { state.animationStarts += 1; }, true); - const bound = new WeakSet(); - const bind = (): void => { - for (const turn of document.querySelectorAll('.maka-transcript-turn')) { - if (bound.has(turn)) continue; - bound.add(turn); - turn.addEventListener('contentvisibilityautostatechange', (event) => { - const skipped = (event as Event & { skipped: boolean }).skipped; - if (skipped === state.skipped.has(turn)) return; - if (skipped) state.skipped.add(turn); - else state.skipped.delete(turn); - state.skippedCount += skipped ? 1 : -1; - }); - } - }; - bind(); - new MutationObserver(bind).observe(document.body, { childList: true, subtree: true }); - }); -} - /** * Watch every frame for a change in the mounted range, and measure what that * change did to the reader. @@ -289,30 +242,6 @@ async function displacement(page: Page): Promise }); } -interface CostSample { - transitionRuns: number; - animationStarts: number; - unfinished: number; - skippedTurns: number; - mountedTurns: number; -} - -async function sample(page: Page): Promise { - return page.evaluate(() => { - const state = window.__makaTranscriptCost; - if (!state) throw new Error('the transcript cost observer is missing'); - return { - transitionRuns: state.transitionRuns, - animationStarts: state.animationStarts, - unfinished: document.body - .getAnimations({ subtree: true }) - .filter((animation) => animation.playState !== 'finished').length, - skippedTurns: state.skippedCount, - mountedTurns: document.querySelectorAll('[data-turn-id]').length, - }; - }); -} - /** * A transcript opened at its tail keeps fetching older history until two * screens of it sit above the reader, and trims what falls outside the band it @@ -363,124 +292,15 @@ async function returnToLatest(page: Page): Promise { await returnLatest.click(); } -/** - * The fixture's own motion contract, asserted as the count it is. - * - * `[data-maka-e2e-fixture]` collapses motion so a fixture render does not - * depend on the millisecond it settles. It used to do that with - * `transition-duration: 0.01ms`, which is not "no transition": the initial - * `transition-property` is `all`, so every element kept a live transition on - * every animatable property and fired transitionrun/start/end on every style - * recalculation — measured here, ~1,200 transitions for one sweep over ten - * mounted Turns, and tens of thousands over a long one. Every timing number - * the replaced suite reported was mostly that. - * - * Nothing downstream can measure the product while the harness generates work - * of its own, so the harness asserts zero. - */ -test('a scroll through the fixture transcript starts no transitions', async ({ - promptRailWindow: page, -}) => { - await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); - const cdp = await page.context().newCDPSession(page); - await observe(page); - await moveToTail(page); - await wheel(page, cdp, { ticks: 40, deltaY: -120 }); - await wheel(page, cdp, { ticks: 40, deltaY: 120 }); - - const cost = await sample(page); - expect(cost.transitionRuns).toBe(0); - expect(cost.animationStarts).toBe(0); - // The reason the declaration exists: a fixture render is a settled state, - // never an entry frame. `none` serves that strictly better than a near-zero - // duration did — that one left transitions still running at sample time. - expect(cost.unfinished).toBe(0); -}); - -/** - * Containment is engaging at all. A `content-visibility: auto` that stops - * skipping — a Turn that gains a property forcing layout, a container query, - * an ancestor that breaks the containment chain — costs nothing that a timing - * threshold would notice on a ten-Turn range, and everything on a long one. - */ -test('the browser skips the Turns the reader has scrolled past', async ({ - promptRailWindow: page, -}) => { - await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); - const cdp = await page.context().newCDPSession(page); - await observe(page); - await moveToTail(page); - // Two viewports up and back: enough for the Turns at the far end of the - // mounted range to leave the browser's relevance margin in both directions. - await wheel(page, cdp, { ticks: 20, deltaY: -120 }); - await wheel(page, cdp, { ticks: 20, deltaY: 120 }); - - expect((await sample(page)).skippedTurns).toBeGreaterThan(0); -}); - -/** - * The bound the Desktop transcript is built on: paging back through a history - * far longer than the retained band mounts a bounded number of Turns, not a - * growing one. Sampled at every page rather than only at the end, because the - * regression is a range that grows while the reader travels and is only trimmed - * once they stop. - */ -test('paging back through the whole history keeps the mounted range bounded', async ({ - promptRailWindow: page, -}) => { - test.setTimeout(120_000); - await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); - const cdp = await page.context().newCDPSession(page); - const turns = page.locator('[data-turn-id]'); - let mountedMax = 0; - let pages = 0; - - for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { - const firstBefore = await turns.first().getAttribute('data-turn-id'); - if (firstBefore === 'turn-prompt-rail-1') break; - // The product asks for history on an upward wheel near the start, so the - // gesture that pages is the gesture a reader makes. How many gestures it - // takes is how tall the resident range happens to be, which is not what - // this test is about — keep scrolling until the range moves. - await expect - .poll(async () => { - await wheel(page, cdp, { ticks: 12, deltaY: -120 }); - return turns.first().getAttribute('data-turn-id'); - }) - .not.toBe(firstBefore); - pages += 1; - mountedMax = Math.max(mountedMax, await turns.count()); - } - - expect(pages).toBeGreaterThan(0); - await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); - expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); - - // Coming back from the far end reads the tail page and rebuilds the window - // around it, so it is slower than the scrolling above. The suite's 10s expect - // timeout is sized for UI that is already on screen, and this step measured - // past it on a loaded CI runner. - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1, { timeout: 30_000 }); - expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); -}); - /** * The scenario #5163 was reported from: quit Desktop, start it again, open a * long Session, and scroll upward through history without stopping. The reader * perceives stalls or jumps around range boundaries. * - * The tests above establish that paging works and stays bounded. Neither says + * A mounted-range bound alone does not say * where the reader ended up while a page was installing, which is the whole of * what that report is about. This one measures it: every frame the mounted - * range changes, whatever Turn the reader can still see must hold its document + * range changes, whatever Turn the reader can still see must hold its viewport * position. * * Displacement in pixels rather than frame timings on purpose — see this file's @@ -489,7 +309,7 @@ test('paging back through the whole history keeps the mounted range bounded', as * content out from under the reader) and only one of them can be asserted * without a clock. */ -test('paging back never moves the reader at a range boundary', async ({ +test('Host history paging stays bounded, preserves the reader and returns to latest', async ({ promptRailWindow: page, }) => { test.setTimeout(120_000); @@ -500,6 +320,8 @@ test('paging back never moves the reader at a range boundary', async ({ const turns = page.locator('[data-turn-id]'); await moveToTail(page); await observeDisplacement(page); + let pages = 0; + let mountedMax = await turns.count(); for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { const firstBefore = await turns.first().getAttribute('data-turn-id'); @@ -516,8 +338,14 @@ test('paging back never moves the reader at a range boundary', async ({ return turns.first().getAttribute('data-turn-id'); }) .not.toBe(firstBefore); + pages += 1; + mountedMax = Math.max(mountedMax, await turns.count()); } + expect(pages).toBeGreaterThan(0); + await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); + expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); + const boundaries = await displacement(page); // The probe has to have seen the thing it measures: a run that paged nothing, // or one where every boundary replaced the range wholesale and carried no @@ -528,4 +356,9 @@ test('paging back never moves the reader at a range boundary', async ({ const displaced = boundaries.filter((boundary) => boundary.worstPx > BOUNDARY_DISPLACEMENT_MAX_PX); expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`) .toEqual([]); + + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1, { timeout: 30_000 }); + expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 172b1f8087..71203598b1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -507,7 +507,6 @@ "importDeclarations": 7, "bridgePaths": {}, "environmentCapabilities": { - "window": 2, "window.addEventListener": 1, "window.removeEventListener": 1 }, @@ -533,7 +532,7 @@ "react": 1 }, "importSpecifiers": 11, - "nonTriviaTokens": 977 + "nonTriviaTokens": 962 }, "src/renderer/app-shell-project-actions.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 4f1fd3603f..a2d5bf5bd5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3902,14 +3902,14 @@ const makaBridge = { // E2E-only async controls. Real users never get these: the preload mirrors the // main process's isolated-E2E gate (startup-context.ts) — MAKA_E2E alone is // not enough without the throwaway profile dir. An armed latch holds the next -// bridge call or an explicitly gated renderer boundary until the test releases +// bridge call until the test releases // it, while a settled-call waiter exposes a deterministic completion boundary // for work whose visible result may intentionally keep the same DOM identity. // The wrappers must be installed BEFORE // exposeInMainWorld: the bridge is cloned into the main world at expose time, // and the exposed clone is sealed against later patching. if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { - type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list' | 'settings.chunk'; + type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list'; const gates = new Map; oneShot: boolean }>(); const releases = new Map void; reject: (error: Error) => void }>(); let nextSessionObservationError: Error | undefined; @@ -3998,9 +3998,6 @@ if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { gates.set(key, { promise, oneShot: options?.oneShot === true }); releases.set(key, { resolve, reject }); }, - wait(key: 'settings.chunk') { - return waitForLatch(key); - }, waitForInvocableSkillsCall(sessionId: string) { return new Promise((resolve) => { const waiters = invocableSkillsWaiters.get(sessionId) ?? []; diff --git a/apps/desktop/src/renderer/app-shell-overlays.tsx b/apps/desktop/src/renderer/app-shell-overlays.tsx index d675b618e1..9a466e8272 100644 --- a/apps/desktop/src/renderer/app-shell-overlays.tsx +++ b/apps/desktop/src/renderer/app-shell-overlays.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { lazy, Suspense, useLayoutEffect, useRef } from 'react'; +import { lazy, Suspense, useLayoutEffect, useRef, type ReactNode } from 'react'; import type { ChatDefaultPermissionMode, SettingsSection, ThemePalette, ThemePreference } from '@maka/core/settings'; import type { ProviderType } from '@maka/core/llm-connections'; import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; @@ -32,15 +32,7 @@ import type { ArchivedTasksBridge } from './settings/tasks-settings-page'; import type { UiLocaleUpdateGate } from './settings/ui-locale-update-gate'; import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; -const SettingsModal = lazy(async () => { - const e2eLatch = ( - window as typeof window & { - makaE2eLatch?: { wait(key: 'settings.chunk'): Promise }; - } - ).makaE2eLatch; - await e2eLatch?.wait('settings.chunk'); - return import('./settings/settings-modal'); -}); +const SettingsModal = lazy(() => import('./settings/settings-modal')); type SearchModalProps = Parameters[0]; @@ -61,6 +53,30 @@ function SettingsModalFallback() { ); } +// Own dismissal outside the lazy chunk, including its Suspense fallback. +export function SettingsOverlay({ onClose, children }: { + onClose(): void; + children: ReactNode; +}) { + const closeRef = useRef(onClose); + useLayoutEffect(() => { + closeRef.current = onClose; + }); + useLayoutEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.key.toLowerCase() !== 'escape' || event.defaultPrevented || + event.ctrlKey || event.metaKey || event.altKey + ) return; + event.preventDefault(); + closeRef.current(); + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, []); + return }>{children}; +} + export function AppShellOverlays(props: { settingsOpen: boolean; closeSettings(): void; @@ -126,36 +142,6 @@ export function AppShellOverlays(props: { onExternalSessionImported, } = props; - const closeSettingsRef = useRef(closeSettings); - useLayoutEffect(() => { - closeSettingsRef.current = closeSettings; - }); - - // The overlay boundary, rather than the lazy Settings chunk, owns Escape. - // That keeps one owner installed before paint for both the Suspense loading - // surface and the resolved Settings surface. Keep the listener stable while - // Settings is open, but read the latest shell callback after every commit. - useLayoutEffect(() => { - if (!settingsOpen) return; - - function onKeyDown(event: globalThis.KeyboardEvent) { - if ( - event.key.toLowerCase() !== 'escape' || - event.defaultPrevented || - event.ctrlKey || - event.metaKey || - event.altKey - ) { - return; - } - event.preventDefault(); - closeSettingsRef.current(); - } - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [settingsOpen]); - // #1045: base commands freeze per open/close; session rows stay live on // visibleSessions/activeId. run() closures read latest options via ref. const commands = useAppShellCommands(paletteOpen, commandOptions); @@ -169,7 +155,7 @@ export function AppShellOverlays(props: { return ( <> {settingsOpen && ( - }> + - + )} { + const turnId = `turn-m-${index}`; + const minutesAgo = (120 - index) * 3; + return [ + user(`msg-m-u-${index}`, turnId, minutesAgo, `第 ${index + 1} 轮:这个模块的边界条件该怎么覆盖?`), + assistant(`msg-m-a-${index}`, turnId, minutesAgo - 1, `第 ${index + 1} 轮回答:先列输入域,再对空、超长、并发三类分别加断言。`), + ]; +}).flat(); + export const ManyTurns: Story = { render: () => ( { - const turnId = `turn-m-${index}`; - const minutesAgo = (120 - index) * 3; - return [ - user(`msg-m-u-${index}`, turnId, minutesAgo, `第 ${index + 1} 轮:这个模块的边界条件该怎么覆盖?`), - assistant(`msg-m-a-${index}`, turnId, minutesAgo - 1, `第 ${index + 1} 轮回答:先列输入域,再对空、超长、并发三类分别加断言。`), - ]; - }).flat(), + messages: manyTurnMessages, }} /> ), }; +// The same transcript and fixture CSS, with enough resident Turns for native +// render skipping but no need to mount the 120-Turn catalog demonstration. +export const TranscriptRenderCost: Story = { + render: () => , + play: async ({ canvasElement }) => { + const scroller = canvasElement.querySelector('[data-chat-scroll-container="true"]'); + if (!scroller) throw new Error('Transcript scrollport is missing'); + const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + await frame(); + await frame(); + let transitions = 0; + let animations = 0; + const skipped = new Set(); + const turns = [...canvasElement.querySelectorAll('.maka-transcript-turn')]; + expect(turns.length).toBeGreaterThan(0); + for (const pseudo of [null, '::before', '::after']) { + const style = getComputedStyle(turns[0], pseudo); + expect(style.transitionProperty).toBe('none'); + expect(style.animationName).toBe('none'); + } + const visibility = (event: Event) => { + if (event.target !== event.currentTarget) return; + const turn = event.currentTarget as Element; + if ((event as Event & { skipped: boolean }).skipped) skipped.add(turn); + else skipped.delete(turn); + }; + for (const turn of turns) turn.addEventListener('contentvisibilityautostatechange', visibility); + const transition = () => { transitions += 1; }; + const animation = () => { animations += 1; }; + canvasElement.addEventListener('transitionrun', transition, true); + canvasElement.addEventListener('animationstart', animation, true); + try { + // No Host paging or wheel routing is under test: move the real Chromium + // scrollport to exercise the fixture CSS and browser render skipping. + for (const direction of [-1, 1]) { + for (let tick = 0; tick < 20; tick += 1) { + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: direction * 120, bubbles: true })); + scroller.scrollTop += direction * 120; + await frame(); + } + } + await waitFor(() => expect(skipped.size).toBeGreaterThan(0)); + expect(transitions).toBe(0); + expect(animations).toBe(0); + expect(canvasElement.getAnimations({ subtree: true }) + .filter((animation) => animation.playState !== 'finished')).toHaveLength(0); + } finally { + for (const turn of turns) turn.removeEventListener('contentvisibilityautostatechange', visibility); + canvasElement.removeEventListener('transitionrun', transition, true); + canvasElement.removeEventListener('animationstart', animation, true); + } + }, +}; + +const pendingSettingsChunk = new Promise(() => {}); +function PendingSettingsChunk(): never { throw pendingSettingsChunk; } +function SettingsLoadingScene() { + const [open, setOpen] = useState(true); + return open ? ( + setOpen(false)}> + ) : null; +} + +// Real path: AppShellOverlays owns dismissal while its Settings chunk suspends. +export const SettingsLoadingEscape: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByRole('status', { name: '正在加载设置' }); + for (const modifier of ['ctrlKey', 'metaKey', 'altKey'] as const) { + const event = new KeyboardEvent('keydown', { + key: 'Escape', [modifier]: true, bubbles: true, cancelable: true, + }); + expect(window.dispatchEvent(event)).toBe(true); + expect(canvas.getByRole('status', { name: '正在加载设置' })).toBeVisible(); + } + expect(window.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', bubbles: true, cancelable: true, + }))).toBe(false); + await waitFor(() => expect(canvas.queryByRole('status', { name: '正在加载设置' })).not.toBeInTheDocument()); + expect(window.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', bubbles: true, cancelable: true, + }))).toBe(true); + }, +}; + // Real path: enough task history to overflow the sidebar. The rail owns the // scrollport while its footer remains inside the fixed shell frame. export const OverflowingSidebar: Story = { diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index e2d7bff260..6e07f52294 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1848,6 +1848,7 @@ function renderedLinkColors(renderedLink: HTMLElement) { } type SettingsStoryProps = { + reopenable?: boolean; section: SettingsSection; connections?: LlmConnection[]; defaultSlug?: string | null; @@ -1902,6 +1903,7 @@ function SettingsStory(props: SettingsStoryProps) { } function SettingsStoryFrame(props: SettingsStoryProps) { + const [open, setOpen] = useState(true); const archivedTasks = useArchivedTasksStoryBridge(props.archivedTaskSessions ?? []); const initialFocusRef = useRef(null); const [uiLocaleUpdateGate] = useState(createUiLocaleUpdateGate); @@ -1925,6 +1927,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) { return ( <> + {props.reopenable && } {/* `100dvh`, not `100%`: `SettingsSurface` is a `Layout height="fill"`, which needs a bounded ancestor to hand its content pane a scroll box. Under Storybook's fullscreen body a percentage height resolves @@ -1943,7 +1946,7 @@ function SettingsStoryFrame(props: SettingsStoryProps) { - + />} @@ -2218,6 +2221,50 @@ export const GeneralCachedRevalidation: Story = { await expect(canvas.queryByRole('alert')).not.toBeInTheDocument(); }, }; + +// Real path: unmount and reopen Settings with its renderer-owned snapshot cache. +// Observe every DOM commit, not just the final ready screen after refresh. +export const GeneralReopenKeepsReadyControls: Story = { + decorators: [withGeneralHostGenerationRevalidationBridge], + render: () => { + resetGenerationStoryBridge(); + return ; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => { + expect(canvas.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled(); + expect(canvas.getByRole('button', { name: '默认模型' })).toBeEnabled(); + }); + await userEvent.click(canvas.getByRole('button', { name: 'Close settings' })); + expect(canvas.queryByRole('textbox', { name: '助手语气偏好' })).not.toBeInTheDocument(); + let missingControls = false; + let loadingAlert = false; + const inspect = () => { + const surface = canvasElement.querySelector('.settingsSurface'); + const main = surface?.querySelector('main, [role="main"]'); + if (!surface || !main) return; + missingControls ||= main.querySelector('textarea') === null || + within(main as HTMLElement).queryByRole('button', { name: '默认模型' }) === null; + loadingAlert ||= [...surface.querySelectorAll('[role="alert"]')] + .some((alert) => alert.textContent?.includes('正在加载设置')); + }; + const observer = new MutationObserver(inspect); + observer.observe(canvasElement, { childList: true, subtree: true, characterData: true }); + try { + await userEvent.click(canvas.getByRole('button', { name: 'Reopen settings' })); + await waitFor(() => { + expect(canvas.getByRole('textbox', { name: '助手语气偏好' })).toBeEnabled(); + expect(canvas.getByRole('button', { name: '默认模型' })).toBeEnabled(); + }); + inspect(); + expect(missingControls).toBe(false); + expect(loadingAlert).toBe(false); + } finally { + observer.disconnect(); + } + }, +}; // A Runtime Host can be replaced without changing its renderer-facing // profileId:hostId key. The lifecycle epoch is the generation boundary: keep // cached Host values visible, revoke their write authority immediately, and From 1f554c4c36748c83ebce3b687d8354aa17b3c60b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 17:22:52 +0800 Subject: [PATCH 2/4] docs(desktop): define Electron test admission rules Generated-by: OpenAI Codex --- apps/desktop/e2e/AGENTS.md | 72 ++++++++++++++++++++++++++++++++ apps/desktop/stories/FIDELITY.md | 6 +-- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/e2e/AGENTS.md diff --git a/apps/desktop/e2e/AGENTS.md b/apps/desktop/e2e/AGENTS.md new file mode 100644 index 0000000000..d919de0885 --- /dev/null +++ b/apps/desktop/e2e/AGENTS.md @@ -0,0 +1,72 @@ + + +# Electron test admission + +Before adding or extending a test here, choose the lowest tier that can expose +the actual defect. Apply this to each behavioral assertion, not just each file. +Existing tests and entries in `../e2e-budget.json` are not exemptions. See #4761, +#4877 and #4892 for the migration history. + +1. Use existing unit, component or integration tests for state, event ordering, + request routing, storage and recovery. Desktop renderer tests already run + from `../src/main/__tests__` through `node --test`; React tests use the existing + fake DOM. The directory name does not restrict them to main-process code. + Shared UI tests live in `../../../packages/ui/src/__tests__`. +2. Use Storybook or a focused browser test when the assertion needs Chromium: + layout, scrolling, selection, focus, animation, viewport or theme behavior. + Reuse `../stories`, `../../../packages/ui/stories` and + [the fidelity convention](../stories/FIDELITY.md). +3. Use Electron only when a lower tier would miss a concrete Electron boundary: + native window/input behavior, Electron preload/main integration, or a + cross-process persistence or lifecycle failure requiring the actual app. + +## Prove the boundary + +- State beside each test which Electron-owned mechanism it verifies and what + defect a lower-tier test would miss. A call used only to prepare the scenario + is not that mechanism. Creating a Host Session does not make a focus test an + IPC test; reading a Host snapshot does not by itself prove Electron is needed. +- Real wheel input, CDP, geometry, localStorage, page reload, or a preload test + latch alone do not establish an Electron requirement. Trace the owner of the + behavior being asserted. Node integration tests can also exercise real Host + and storage boundaries. +- Do not justify a renderer-only assertion by another test in the same file, + an already-open window, or an unchanged test count. Keep Electron journeys + focused on their necessary boundaries; move independent renderer contracts. +- Search existing lower-tier coverage before adding a replacement. Extend the + actual component/controller/service seam; do not copy product logic into a + second shell or introduce a global render-completion protocol for tests. + +## Migrate and verify + +- Establish that the replacement detects the original defect, preferably by + reverting the relevant behavior or a targeted mutation. Verify the behavioral + failure, not merely an import/type error or the absence of a new helper. +- Delete replaced E2E cases, duplicate assertions and unreferenced fixture hooks + in the same PR. Preserve any independent native or cross-process protection. +- Do not raise timeouts, add retries, or weaken assertions to make a migration + pass. Diagnose a red replacement before deciding it is a harness problem. +- Update `../e2e-budget.json` with counts and concrete boundary reasons, then run + `npm run check:e2e-budget` from the repository root. This checks inventory + consistency; a green result is not proof of correct tier selection. +- Build before running compiled tests or Storybook smoke. Run Electron tests + from `apps/desktop` because the fixtures use that working directory. Report + transferred/lost protection and fixture launches or traversals removed, + separately from the number of test declarations. diff --git a/apps/desktop/stories/FIDELITY.md b/apps/desktop/stories/FIDELITY.md index 17bc65f42b..cba6648f2b 100644 --- a/apps/desktop/stories/FIDELITY.md +++ b/apps/desktop/stories/FIDELITY.md @@ -44,10 +44,10 @@ A story earns its place by rendering pixels no other story renders. A second lev Two facts decide it, and both were guessed wrong once: -- **Where a story renders.** CI mounts every story exactly once at 1280 wide in light. It does not maintain a viewport, theme or screenshot matrix. Responsive and theme behaviour belongs in a focused component contract or the real desktop E2E harness. +- **Where a story renders.** The smoke runner owns the actual viewport and theme coverage; check `scripts/storybook-visual-smoke.mjs` rather than assuming Storybook toolbar parameters create CI jobs. It currently selects a narrow viewport for story IDs containing `narrow` and additional theme/palette/forced-color runs for selected sentinels. A responsive or theme contract needs an explicit browser scenario for its required conditions, not an Electron window merely because the default smoke does not exercise them. - **Whether `play` reaches the state.** `play` drives a story into the state a reviewer needs to see, and CI runs it — so the state it lands on is the state the smoke reads, and a story that only differs by a `play` step is a second state, not a variant. -Extra stories still cost: a reviewer scanning the sidebar cannot tell which entry is the page, and duplicates re-render the same pixels every run while claiming coverage they do not add. Where a state matters but renders nothing new, pin it somewhere that runs — a `packages/ui` test or an e2e journey. +Extra stories still cost: a reviewer scanning the sidebar cannot tell which entry is the page, and duplicates re-render the same pixels every run while claiming coverage they do not add. Where a state matters but renders nothing new, prefer an existing unit or component test. A browser or Electron test needs a boundary that the lower tier cannot verify; see [Electron admission](../e2e/AGENTS.md). ## The frame matters, not just the component @@ -67,7 +67,7 @@ The render smoke waits for Storybook's `storyFinished` event before it reads the That makes `play` the right home for a behavioural contract whose subject is the browser: a live Selection, a caret between text nodes, an undo transaction, a portal's identity across a re-render. None of those exist in a `packages/ui` DOM shim, and none of them need Electron. -It is still not a place for geometry or theme matrices. CI mounts every story once, at 1280 wide, in light; a contract that depends on any other viewport or scheme belongs in a `packages/ui` test or the desktop E2E harness. And a rule that is pure state — which commands a Session offers, what a query parses to — belongs in a unit test, where it costs milliseconds instead of a browser. +Real layout, browser scrolling, and responsive/theme behavior belong in this browser tier. Assert the relevant geometry against the production component and frame, with the viewport and theme explicitly exercised by the runner. A fake DOM cannot establish real layout, and needing another viewport is not a reason to launch Electron. Pure state — which commands a Session offers, what a query parses to — belongs in a unit test, where it costs milliseconds instead of a browser. Write the assertion so it can only pass for the reason it names. A story that mounts the surface and then observes it cannot see anything that happened during the mount, so a probe that must be installed first (a constructor count, an event before the first paint) belongs in a test that owns the global. From cf38820e1ea7760562d017d83d35bbc9bf067750 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 17:26:54 +0800 Subject: [PATCH 3/4] docs(storybook): add scoped test tier guidance Generated-by: OpenAI Codex --- apps/desktop/stories/AGENTS.md | 48 ++++++++++++++++++++++++++++++++++ packages/ui/stories/AGENTS.md | 41 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 apps/desktop/stories/AGENTS.md create mode 100644 packages/ui/stories/AGENTS.md diff --git a/apps/desktop/stories/AGENTS.md b/apps/desktop/stories/AGENTS.md new file mode 100644 index 0000000000..cc802fbf99 --- /dev/null +++ b/apps/desktop/stories/AGENTS.md @@ -0,0 +1,48 @@ + + +# Desktop Storybook + +Before adding or changing stories, read [FIDELITY.md](FIDELITY.md), the shared +convention for both Desktop and UI stories. + +- Prefer existing unit/component tests for state, event ordering, request + routing and persistence. Desktop renderer tests can live in + `../src/main/__tests__` using the existing Node/React test seam. +- Use this browser tier for real layout, scrolling, selection, focus and + animation that a fake DOM cannot verify. Another viewport or theme is not a + reason to move the test to Electron; apply [Electron admission](../e2e/AGENTS.md) + before escalating. +- Reuse the production component, controller and frame. Extend an existing + story when it already reaches the behavior; do not duplicate product logic or + build a second shell to make the test easy. +- Check the smoke runner's actual viewport/theme jobs. Storybook toolbar + settings alone do not establish automated coverage. Follow FIDELITY for + reachable states, framing and assertion timing. +- For migrated regression coverage, demonstrate that the assertion detects the + original defect, then remove the replaced E2E assertions and unused fixtures + in the same PR. Do not compensate for failures with longer timeouts, retries + or a global render-completion protocol. + +The Desktop Storybook host also discovers `packages/ui/stories`. From the +repository root, run `npm --workspace @maka/desktop run typecheck:stories` and +`npm --workspace @maka/desktop run build-storybook` before +`npm --workspace @maka/desktop run smoke:storybook`. Smoke does not rebuild the +catalog. For focused runs, verify that the relevant `play` functions finish; +an image of the final screen alone does not prove their assertions passed. diff --git a/packages/ui/stories/AGENTS.md b/packages/ui/stories/AGENTS.md new file mode 100644 index 0000000000..8c4399b55c --- /dev/null +++ b/packages/ui/stories/AGENTS.md @@ -0,0 +1,41 @@ + + +# Shared UI Storybook + +Read [the shared fidelity convention](../../../apps/desktop/stories/FIDELITY.md) +before adding or changing stories. Use its rules rather than maintaining a +second version here. + +- Prefer `../src/__tests__` for pure state and component behavior that the + existing Node/React harness can verify. Do not create a browser story solely + because a test happens to render React. +- Keep real Chromium layout, scrolling, selection, focus and animation checks + in the browser tier. Fake DOM geometry is not layout evidence; viewport, + theme or wheel requirements alone do not justify Electron. Follow + [Electron admission](../../../apps/desktop/e2e/AGENTS.md) before escalating. +- Exercise the shared component through its production props and behavior. + Desktop-specific shell wiring belongs in the Desktop stories; do not copy + that shell into a UI fixture. Preserve the real owning frame for geometry. +- Reuse existing scenarios and imports, and prove migrated regression + assertions can detect the defect before deleting their old coverage. + +These stories run in the Desktop Storybook host. Follow the +[Desktop validation instructions](../../../apps/desktop/stories/AGENTS.md) +for typecheck, build and smoke; there is no separate UI Storybook runner. From 93094cacb71587f4e22ebca44da19bdb52feb215 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 12 Sep 2026 17:37:19 +0800 Subject: [PATCH 4/4] fix(desktop): remove unused settings modal re-exports Generated-by: OpenAI Codex --- apps/desktop/renderer-architecture.json | 1 - apps/desktop/src/renderer/settings/settings-modal.tsx | 3 --- 2 files changed, 4 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 71203598b1..07d0afc68d 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3499,7 +3499,6 @@ "actionFactories": [], "dependencyPaths": { "../locales/settings-shared-copy": 1, - "./settings-nav": 1, "./settings-surface": 1, "@maka/ui": 1, "react": 1 diff --git a/apps/desktop/src/renderer/settings/settings-modal.tsx b/apps/desktop/src/renderer/settings/settings-modal.tsx index 3cf5cce934..fff4b75115 100644 --- a/apps/desktop/src/renderer/settings/settings-modal.tsx +++ b/apps/desktop/src/renderer/settings/settings-modal.tsx @@ -28,9 +28,6 @@ import { SettingsSurface } from './settings-surface'; import type { ArchivedTasksBridge } from './tasks-settings-page'; import type { UiLocaleUpdateGate } from './ui-locale-update-gate'; -export { SETTINGS_NAV } from './settings-nav'; -export type { SettingsNavGroup } from './settings-nav'; - export default function SettingsModal(props: { onClose(): void; themePref: ThemePreference;