From c9c6cfc0eea2ef00673413beb9eaf50c5f263d2b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 21:00:59 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20background-agents=20tray=20?= =?UTF-8?q?=E2=80=94=20down-arrow=20focus=20+=20enter=20to=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/test/agentsTray.test.tsx | 265 ++++++++++++++++++ ui-opentui/src/view/App.tsx | 23 +- ui-opentui/src/view/agentsTray.tsx | 203 ++++++++++++++ ui-opentui/src/view/composer.tsx | 38 ++- .../src/view/overlays/agentsDashboard.tsx | 16 +- ui-opentui/vitest.config.ts | 9 +- 6 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 ui-opentui/src/test/agentsTray.test.tsx create mode 100644 ui-opentui/src/view/agentsTray.tsx diff --git a/ui-opentui/src/test/agentsTray.test.tsx b/ui-opentui/src/test/agentsTray.test.tsx new file mode 100644 index 00000000000..01117ee09e4 --- /dev/null +++ b/ui-opentui/src/test/agentsTray.test.tsx @@ -0,0 +1,265 @@ +/** + * Background-agents tray tests (Epic 2.7). Headless frames through the real + * App + Composer + AgentsTray with a simulated keyboard: + * + * - visibility: nothing rendered with 0 running agents; a one-line muted + * indicator with the count otherwise; completed/failed agents drop out. + * - focus-routing table: Down on an EMPTY composer with running agents + * focuses/expands the tray; Down with text keeps its meaning; Down with + * the slash menu open stays menu navigation (routeMenuKey integration + * pin); Down with 0 agents keeps prompt history; Esc from the tray + * returns focus to the composer; a printable key from the tray bounces + * focus back AND inserts the char (the composer's reclaim rule). + * - Enter on a tray row opens the agents dashboard preselected on that row. + * + * The onType wiring mirrors slashMenu.test.tsx (planCompletion → fake catalog) + * so the menu-precedence pin runs against entry-parity completions. + */ +import { describe, expect, test } from 'vitest' + +import { createPromptHistory } from '../logic/history.ts' +import { planCompletion } from '../logic/slash.ts' +import { createSessionStore, type CompletionItem, type SessionStore } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { isTrayAgent } from '../view/agentsTray.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +const INDICATOR = 'running — ↓ to inspect' +const EXPANDED_HINT = 'Enter inspect' + +/** Fake gateway catalog (what `complete.slash` would return for a `/` prefix). */ +const CATALOG: CompletionItem[] = [ + { display: '/clear', meta: 'clear the transcript', text: '/clear' }, + { display: '/copy', meta: 'copy the last response', text: '/copy' } +] + +interface Harness { + probe: RenderProbe + store: SessionStore + submitted: string[] + typed: string[] +} + +/** Mount the real App (entry-parity onType, like slashMenu.test.tsx). */ +async function mountApp(historyEntries: string[] = []): Promise { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const submitted: string[] = [] + const typed: string[] = [] + const history = createPromptHistory({ initial: historyEntries }) + const onType = (text: string) => { + typed.push(text) + const plan = planCompletion(text) + if (!plan || plan.method !== 'complete.slash') { + store.clearCompletions() + return + } + const q = String(plan.params.text).toLowerCase() + const items = CATALOG.filter(c => c.text.startsWith(q) && c.text !== q) + if (items.length) store.setCompletions(items, plan.from) + else store.clearCompletions() + } + const probe = await renderProbe( + () => ( + store.state.theme}> + submitted.push(t)} onType={onType} history={history} /> + + ), + // kitty keyboard: a SIMULATED lone ESC never parses under legacy input, and + // the Esc-from-tray test needs it. + { height: 26, kittyKeyboard: true, width: 70 } + ) + return { probe, store, submitted, typed } +} + +const spawn = (store: SessionStore, id: string, goal: string) => + store.apply({ type: 'subagent.start', payload: { depth: 0, goal, subagent_id: id } }) + +const complete = (store: SessionStore, id: string) => + store.apply({ type: 'subagent.complete', payload: { subagent_id: id, summary: 'done' } }) + +describe('agents tray — visibility', () => { + test('isTrayAgent: running-ish statuses are in; ALL terminal statuses are out', () => { + for (const status of ['running', 'thinking', 'tool', 'working']) { + expect(isTrayAgent({ depth: 0, goal: 'g', id: 'x', status })).toBe(true) + } + // `complete` is the store fallback; the LIVE gateway sends delegate_tool's + // payload status verbatim — `completed`/`failed`/`error`/`timeout`/`interrupted` + // (verified live: the success path emits status="completed"). + for (const status of ['complete', 'completed', 'failed', 'error', 'timeout', 'interrupted']) { + expect(isTrayAgent({ depth: 0, goal: 'g', id: 'x', status })).toBe(false) + } + }) + + test('0 running agents → the tray renders nothing', async () => { + const h = await mountApp() + try { + expect(h.probe.frame()).not.toContain(INDICATOR) + } finally { + h.probe.destroy() + } + }) + + test('2 running agents → a one-line indicator with the count', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + spawn(h.store, 'a2', 'compile Y') + const frame = await h.probe.waitForFrame(f => f.includes(INDICATOR)) + expect(frame).toContain(`⚡ 2 agents ${INDICATOR}`) + expect(frame).not.toContain(EXPANDED_HINT) // collapsed until focused + } finally { + h.probe.destroy() + } + }) + + test('completed agents drop out; the tray empties when all finish', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + spawn(h.store, 'a2', 'compile Y') + await h.probe.waitForFrame(f => f.includes('⚡ 2 agents')) + complete(h.store, 'a1') + const one = await h.probe.waitForFrame(f => f.includes('⚡ 1 agent ')) + expect(one).toContain(`⚡ 1 agent ${INDICATOR}`) + complete(h.store, 'a2') + const none = await h.probe.waitForFrame(f => !f.includes(INDICATOR)) + expect(none).not.toContain('⚡') + } finally { + h.probe.destroy() + } + }) +}) + +describe('agents tray — Down-arrow focus routing', () => { + test('Down on an EMPTY composer with running agents focuses + expands the tray', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + spawn(h.store, 'a2', 'compile Y') + await h.probe.waitForFrame(f => f.includes(INDICATOR)) + h.probe.keys.pressArrow('down') + const frame = await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT)) + // rows show goal + status, with the first row selected + expect(frame).toContain('research X') + expect(frame).toContain('compile Y') + expect(frame).toContain('● running') + expect(frame).toMatch(/▸ ● running\s+research X/) + expect(frame).not.toContain(INDICATOR) // collapsed line replaced by rows + } finally { + h.probe.destroy() + } + }) + + test('Down with TEXT in the composer keeps its meaning (no tray focus)', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + await h.probe.waitForFrame(f => f.includes(INDICATOR)) + await h.probe.keys.typeText('hello') + await h.probe.settle() + h.probe.keys.pressArrow('down') + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).toContain('hello') // text untouched + expect(frame).not.toContain(EXPANDED_HINT) + expect(frame).toContain(INDICATOR) // still just the indicator + } finally { + h.probe.destroy() + } + }) + + test('Down with the slash menu open stays MENU navigation (routeMenuKey pin)', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + await h.probe.waitForFrame(f => f.includes(INDICATOR)) + await h.probe.keys.typeText('/') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/copy')) + h.probe.keys.pressArrow('down') // menu: /clear → /copy (NOT the tray) + await h.probe.settle() + expect(h.probe.frame()).not.toContain(EXPANDED_HINT) + h.probe.keys.pressEnter() // accepts the highlighted command + await h.probe.settle() + expect(h.typed.at(-1)).toBe('/copy ') + expect(h.submitted).toEqual([]) + } finally { + h.probe.destroy() + } + }) + + test('Down with 0 running agents keeps prompt history as today', async () => { + const h = await mountApp(['older prompt']) + try { + h.probe.keys.pressArrow('up') // recall + await h.probe.settle() + expect(h.probe.frame()).toContain('older prompt') + h.probe.keys.pressArrow('down') // back to the (empty) draft — not a tray focus + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).not.toContain('older prompt') + expect(frame).not.toContain(EXPANDED_HINT) + } finally { + h.probe.destroy() + } + }) + + test('Esc from the focused tray collapses it and refocuses the composer', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + await h.probe.waitForFrame(f => f.includes(INDICATOR)) + h.probe.keys.pressArrow('down') + await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT)) + h.probe.keys.pressEscape() + const frame = await h.probe.waitForFrame(f => !f.includes(EXPANDED_HINT)) + expect(frame).toContain(INDICATOR) // back to the collapsed line + await h.probe.keys.typeText('hi') // composer has focus again + await h.probe.settle() + expect(h.probe.frame()).toContain('hi') + } finally { + h.probe.destroy() + } + }) + + test('a printable key from the focused tray bounces to the composer AND inserts', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + await h.probe.waitForFrame(f => f.includes(INDICATOR)) + h.probe.keys.pressArrow('down') + await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT)) + await h.probe.keys.typeText('x') + const frame = await h.probe.waitForFrame(f => !f.includes(EXPANDED_HINT)) + expect(frame).toContain(INDICATOR) // tray collapsed (textarea reclaimed focus) + expect(frame).toContain('x') // …and the char landed in the composer + } finally { + h.probe.destroy() + } + }) +}) + +describe('agents tray — Enter opens the dashboard preselected', () => { + test('Down to the second row + Enter → dashboard open on THAT agent', async () => { + const h = await mountApp() + try { + spawn(h.store, 'a1', 'research X') + spawn(h.store, 'a2', 'compile Y') + await h.probe.waitForFrame(f => f.includes(INDICATOR)) + h.probe.keys.pressArrow('down') // focus the tray (row 0) + await h.probe.waitForFrame(f => f.includes(EXPANDED_HINT)) + h.probe.keys.pressArrow('down') // select row 1 (compile Y) + await h.probe.settle() + h.probe.keys.pressEnter() + const frame = await h.probe.waitForFrame(f => f.includes('⛓ Agents')) + expect(h.store.state.dashboard).toBe(true) + expect(h.store.state.dashboardAgent).toBe('a2') + expect(frame).toMatch(/▸ ● running\s+compile Y/) // master list preselected + expect(h.submitted).toEqual([]) // Enter opened the dashboard, no submit + } finally { + h.probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index f31099da748..bcbecf2f112 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -19,6 +19,7 @@ import { deferClose } from '../logic/defer.ts' import type { PromptHistory } from '../logic/history.ts' import type { PasteStore } from '../logic/pastes.ts' import type { SessionStore } from '../logic/store.ts' +import { AgentsTray, type AgentsTrayApi } from './agentsTray.tsx' import { Composer } from './composer.tsx' import { DimensionsProvider } from './dimensions.tsx' import { Header } from './header.tsx' @@ -52,6 +53,11 @@ const NO_SESSION = () => undefined export function App(props: AppProps) { const theme = useTheme() + // Background-agents tray focus plumbing (Epic 2.7): Down on an empty composer + // hands focus to the tray; Esc from the tray hands it back. Plain refs — the + // tray persists across composer remounts (overlay close re-registers focus). + let trayApi: AgentsTrayApi | undefined + let focusComposer: (() => void) | undefined const blocked = () => props.store.state.prompt !== undefined const pager = () => props.store.state.pager const dashboard = () => props.store.state.dashboard @@ -105,6 +111,8 @@ export function App(props: AppProps) { history={props.history} onImagePaste={props.onImagePaste} pasteStore={props.pasteStore} + onFocusDown={() => trayApi?.focusTray() ?? false} + registerFocus={fn => (focusComposer = fn)} /> } > @@ -132,13 +140,26 @@ export function App(props: AppProps) { )} + {/* background-agents tray (Epic 2.7): renders nothing with no + running agents; a one-line indicator otherwise; expands while + focused (composer Down). Enter opens the dashboard on that row. */} + props.store.openDashboard(id)} + onExit={() => focusComposer?.()} + bind={api => (trayApi = api)} + /> } > {p => } - + diff --git a/ui-opentui/src/view/agentsTray.tsx b/ui-opentui/src/view/agentsTray.tsx new file mode 100644 index 00000000000..a63dc251dd4 --- /dev/null +++ b/ui-opentui/src/view/agentsTray.tsx @@ -0,0 +1,203 @@ +/** + * AgentsTray — the background-agents tray docked below the composer (Epic 2.7). + * + * Collapsed (unfocused): one muted, always-honest line — `⚡ N agents running — + * ↓ to inspect`. Nothing at all when no agent is running (no stolen transcript + * height). Expanded (focused): one row per RUNNING subagent (status not + * complete/failed) showing status · goal · elapsed-ish · last activity line, + * with a themed highlight on the selection. + * + * Focus routing (the hard part): the tray takes NATIVE focus (its root box is + * focusable) — `focusRenderable` blurs the composer textarea for us, and + * focusing the textarea back blurs the box, whose BLURRED event is the single + * collapse trigger. The composer hands focus over via `onFocusDown` (Down on an + * EMPTY composer, no dropdown — see composer.tsx); while the tray is focused: + * - Up/Down move the selection (composer's history handler is gated on the + * textarea being focused, so it stays out of the way); + * - Enter opens the agents dashboard preselected on the row (the dashboard + * replaces the input zone, unmounting the tray → destroy → blur → collapse); + * - Esc returns focus to the composer (`onExit` → the composer's focus()); + * - a printable key is NOT handled here — the composer's reclaim rule focuses + * the textarea and inserts the char, and the resulting blur collapses us. + * The `defaultPrevented` guard keeps the very Down that focused the tray (the + * composer preventDefaults it) from also moving the selection. + */ +import { RenderableEvents, type BoxRenderable } from '@opentui/core' +import { useKeyboard } from '@opentui/solid' +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' + +import type { SubagentInfo } from '../logic/store.ts' +import { elapsedSeconds, useElapsedTick } from './elapsed.ts' +import { useTheme } from './theme.tsx' + +/** What the App binds to hand the tray keyboard focus (composer Down). */ +export interface AgentsTrayApi { + /** Try to take focus; false when ineligible (no running agents / not mounted). */ + focusTray: () => boolean +} + +/** Terminal subagent statuses — everything the wire can end a branch with. + * `complete` is the store's fallback mapping for a status-less + * `subagent.complete`; the LIVE gateway sends the payload status verbatim from + * delegate_tool: `completed` / `failed` / `error` / `timeout` / `interrupted`. */ +const TERMINAL_STATUSES = new Set(['complete', 'completed', 'failed', 'error', 'timeout', 'interrupted']) + +/** Tray membership: a subagent still doing work. */ +export function isTrayAgent(sa: SubagentInfo): boolean { + return !TERMINAL_STATUSES.has(sa.status) +} + +/** `m:ss` for the row's elapsed-ish counter. */ +function fmtElapsed(secs: number): string { + return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}` +} + +/** Keep a row's activity tail to one line's worth. */ +function truncate(s: string, max = 48): string { + const flat = s.replace(/\s+/g, ' ').trim() + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat +} + +function statusColor(status: string, theme: ReturnType): string { + const c = theme().color + if (status === 'tool' || status === 'working') return c.accent + if (status.includes('error')) return c.error + return c.warn +} + +export function AgentsTray(props: { + subagents: SubagentInfo[] + /** Enter on a row — open that agent in the dashboard. */ + onOpen: (id: string) => void + /** Esc (or the tray emptying while focused) — give focus back to the composer. */ + onExit?: (() => void) | undefined + /** Receives the focus-handoff API once (the App wires it to the composer's Down). */ + bind?: ((api: AgentsTrayApi) => void) | undefined +}) { + const theme = useTheme() + const running = createMemo(() => props.subagents.filter(isTrayAgent)) + const [focused, setFocused] = createSignal(false) + const [sel, setSel] = createSignal(0) + // Clamp against a shrinking list (an agent above the selection completing). + const selected = () => Math.min(sel(), Math.max(0, running().length - 1)) + let boxRef: BoxRenderable | undefined + + // First-seen wall clock per agent id — the subagent stream carries no start + // timestamp, so "elapsed-ish" is time since the tray first saw the agent. + // Non-reactive Map; rows repaint via the shared 1s tick while expanded. + const firstSeen = new Map() + createEffect(() => { + for (const sa of running()) if (!firstSeen.has(sa.id)) firstSeen.set(sa.id, Date.now()) + }) + + const attach = (el: BoxRenderable) => { + boxRef = el + // The single collapse trigger: native focus left the box (printable-key + // reclaim by the composer, an overlay opening, or unmount-destroy). + el.on(RenderableEvents.BLURRED, () => setFocused(false)) + } + + props.bind?.({ + focusTray: () => { + if (running().length === 0 || !boxRef) return false + setSel(0) + setFocused(true) + boxRef.focus() + return true + } + }) + + // The last running agent finished while the tray was focused: the box is about + // to unmount — hand focus back to the composer instead of leaving it nowhere. + createEffect(() => { + if (focused() && running().length === 0) { + setFocused(false) + props.onExit?.() + } + }) + + useKeyboard(key => { + // defaultPrevented: the Down that HANDED us focus was consumed by the composer. + if (!focused() || key.defaultPrevented) return + if (key.name === 'up') { + setSel(Math.max(0, selected() - 1)) + key.preventDefault() + } else if (key.name === 'down') { + setSel(Math.min(running().length - 1, selected() + 1)) + key.preventDefault() + } else if (key.name === 'return') { + const sa = running()[selected()] + if (sa) props.onOpen(sa.id) // dashboard replaces the input zone → unmount → blur → collapse + key.preventDefault() + } else if (key.name === 'escape') { + boxRef?.blur() + setFocused(false) + props.onExit?.() + } + }) + + return ( + 0}> + + + {`⚡ ${running().length} agent${running().length === 1 ? '' : 's'} running — ↓ to inspect`} + + } + > + + + + + ) +} + +/** The expanded rows — split out so the 1s elapsed tick is only subscribed while + * the tray is focused (the `` scope owns the subscription's onCleanup). */ +function TrayRows(props: { agents: SubagentInfo[]; selected: number; firstSeen: Map }) { + const theme = useTheme() + const tick = useElapsedTick() + return ( + + + {(sa, i) => { + const active = () => i() === props.selected + const last = () => sa.trace?.at(-1) ?? sa.thought + const secs = () => (tick(), elapsedSeconds(props.firstSeen.get(sa.id) ?? Date.now())) + return ( + + + + {active() ? '▸ ' : ' '} + + {`● ${sa.status}`} + {` ${truncate( + sa.goal || sa.id, + 72 + )}`} + {` · ${fmtElapsed(secs())}`} + {last() ? ` ${truncate(last() ?? '')}` : ''} + + + ) + }} + + + ↑/↓ select · Enter inspect · Esc back + + + ) +} diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index d7fa2f0aca7..eb138f62ce9 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -89,6 +89,11 @@ export function Composer(props: { history?: PromptHistory | undefined onImagePaste?: (() => void) | undefined pasteStore?: PasteStore | undefined + /** Down on an EMPTY focused composer with no dropdown open (Epic 2.7 tray + * handoff): return true to consume the key (the tray took focus). */ + onFocusDown?: (() => boolean) | undefined + /** Hands the parent a "focus the textarea" callback (Esc from the tray). */ + registerFocus?: ((focus: () => void) => void) | undefined }) { const theme = useTheme() const dims = useDimensions() @@ -178,10 +183,32 @@ export function Composer(props: { return } } - // 2) prompt history (item 6): Up at the first line → older prompt; Down at the + // 2) background-agents tray handoff (Epic 2.7): Down on an EMPTY focused + // composer with NO dropdown open offers focus to the tray. The parent decides + // eligibility (≥1 running agent; overlays/prompts replace the composer + // entirely, so they can't get here) and returns true when it took focus — + // preventDefault keeps the consumed Down out of the textarea AND out of the + // tray's own selection handler (it skips defaultPrevented keys). Otherwise + // Down keeps every existing meaning (menu nav above, history below). + if ( + key.name === 'down' && + !key.ctrl && + !key.meta && + !key.option && + menu.length === 0 && + ta?.focused === true && + ta.plainText === '' && + props.onFocusDown?.() === true + ) { + key.preventDefault() + return + } + // 3) prompt history (item 6): Up at the first line → older prompt; Down at the // last line → newer/draft. At the boundary the textarea's own up/down is a // no-op, so there's no conflict; mid-buffer it falls through to cursor moves. - if (ta && props.history) { + // Gated on the textarea being FOCUSED: while focus is elsewhere (the agents + // tray, the transcript scrollbox) arrows must not recall history into the buffer. + if (ta?.focused && props.history) { if (key.name === 'up' && ta.logicalCursor.row === 0) { const entry = props.history.prev(ta.plainText) if (entry !== null) setBuffer(entry) @@ -197,7 +224,7 @@ export function Composer(props: { props.history.reset() } } - // 3) always-active input (item 2): a printable key while the textarea lost + // 4) always-active input (item 2): a printable key while the textarea lost // focus reclaims it. The renderer runs this GLOBAL handler BEFORE routing the // key to the focused renderable, so after focus() the SAME keystroke is still // delivered to the (now-focused) textarea — do NOT insert it here too, or the @@ -207,7 +234,10 @@ export function Composer(props: { } }) - onMount(() => ta?.focus()) + onMount(() => { + ta?.focus() + props.registerFocus?.(() => ta?.focus()) + }) return ( diff --git a/ui-opentui/src/view/overlays/agentsDashboard.tsx b/ui-opentui/src/view/overlays/agentsDashboard.tsx index 36aed97ecb4..9779be7eb9a 100644 --- a/ui-opentui/src/view/overlays/agentsDashboard.tsx +++ b/ui-opentui/src/view/overlays/agentsDashboard.tsx @@ -26,7 +26,12 @@ function statusColor(status: string, theme: ReturnType): string return c.warn } -export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) { +export function AgentsDashboard(props: { + subagents: SubagentInfo[] + onClose: () => void + /** Subagent id to preselect on open (Enter from the agents tray — Epic 2.7). */ + preselect?: string | undefined +}) { const theme = useTheme() const [sel, setSel] = createSignal(0) let rootRef: BoxRenderable | undefined @@ -38,7 +43,14 @@ export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () // Close (Esc/Ctrl+C) is the native keymap; select + scroll stay in the raw global // handler below. Focus the root box on mount so the focus-within close layer is active. - onMount(() => rootRef?.focus()) + // A `preselect` id (tray Enter) lands the selection on that agent's row. + onMount(() => { + rootRef?.focus() + if (props.preselect) { + const idx = props.subagents.findIndex(sa => sa.id === props.preselect) + if (idx >= 0) setSel(idx) + } + }) useCloseLayer( () => rootRef, () => props.onClose() diff --git a/ui-opentui/vitest.config.ts b/ui-opentui/vitest.config.ts index 08d6ff64357..ad258da9e78 100644 --- a/ui-opentui/vitest.config.ts +++ b/ui-opentui/vitest.config.ts @@ -63,7 +63,14 @@ export default defineConfig({ // runtime, so post-mount store updates would never repaint test frames // (@opentui/solid itself deep-imports `solid-js/dist/solid.js`, which is // exactly where the alias points — one shared runtime). - inline: [/solid-js[/\\]store/] + // + // Same story for @opentui/keymap: externalized, its bare `import "solid-js"` + // gets the SSR server build — a SECOND runtime whose `Owner` is null inside + // client-runtime computations, so `useKeymap()` threw "Keymap not found" for + // any overlay mounted AFTER the initial render (dashboard/pager opened by a + // store update). The production build is immune (esbuild bundles the keymap + // and force-resolves solid-js to the client build for every importer). + inline: [/solid-js[/\\]store/, /@opentui[/\\]keymap/] } } }