diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 11e617c74df..1fab5d0e046 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3542,10 +3542,18 @@ } /* Stagger entrance */ -.ov-cards .ov-card:nth-child(1) { animation-delay: 0ms; } -.ov-cards .ov-card:nth-child(2) { animation-delay: 50ms; } -.ov-cards .ov-card:nth-child(3) { animation-delay: 100ms; } -.ov-cards .ov-card:nth-child(4) { animation-delay: 150ms; } +.ov-cards .ov-card:nth-child(1) { + animation-delay: 0ms; +} +.ov-cards .ov-card:nth-child(2) { + animation-delay: 50ms; +} +.ov-cards .ov-card:nth-child(3) { + animation-delay: 100ms; +} +.ov-cards .ov-card:nth-child(4) { + animation-delay: 150ms; +} /* Recent sessions widget */ .ov-recent { diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 19530865039..c05bdcbe98e 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -43,43 +43,6 @@ } } -/* =========================================== - Sidebar - =========================================== */ - -.config-sidebar { - display: flex; - flex-direction: column; - background: var(--bg-accent); - border-right: 1px solid var(--border); - min-height: 0; - overflow: hidden; -} - -:root[data-theme-mode="light"] .config-sidebar { - background: var(--bg-hover); -} - -.config-sidebar__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid var(--border); -} - -.config-sidebar__title { - font-weight: 600; - font-size: 13px; - letter-spacing: -0.01em; -} - -.config-sidebar__footer { - margin-top: auto; - padding: 12px; - border-top: 1px solid var(--border); -} - /* Search */ .config-search { display: grid; @@ -162,208 +125,6 @@ color: var(--text); } -.config-search__hint { - display: grid; - gap: 6px; -} - -.config-search__hint-label { - font-size: 10px; - font-weight: 600; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.03em; - white-space: nowrap; -} - -.config-search__tag-picker { - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - transition: - border-color var(--duration-fast) ease, - box-shadow var(--duration-fast) ease, - background var(--duration-fast) ease; -} - -.config-search__tag-picker[open] { - border-color: var(--accent); - box-shadow: var(--focus-ring); - background: var(--bg-hover); -} - -:root[data-theme-mode="light"] .config-search__tag-picker { - background: white; -} - -.config-search__tag-trigger { - list-style: none; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-height: 30px; - padding: 6px 8px; - cursor: pointer; -} - -.config-search__tag-trigger::-webkit-details-marker { - display: none; -} - -.config-search__tag-placeholder { - font-size: 11px; - color: var(--muted); -} - -.config-search__tag-chips { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - min-width: 0; -} - -.config-search__tag-chip { - display: inline-flex; - align-items: center; - border: 1px solid var(--border); - border-radius: var(--radius-full); - padding: 2px 7px; - font-size: 10px; - font-weight: 500; - color: var(--text); - background: var(--bg); -} - -.config-search__tag-chip--count { - color: var(--muted); -} - -.config-search__tag-caret { - color: var(--muted); - font-size: 12px; - line-height: 1; -} - -.config-search__tag-picker[open] .config-search__tag-caret { - transform: rotate(180deg); -} - -.config-search__tag-menu { - max-height: 104px; - overflow-y: auto; - border-top: 1px solid var(--border); - padding: 6px; - display: grid; - gap: 6px; -} - -.config-search__tag-option { - display: block; - width: 100%; - border: 1px solid transparent; - border-radius: var(--radius-sm); - padding: 6px 8px; - background: transparent; - color: var(--muted); - font-size: 11px; - text-align: left; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease, - border-color var(--duration-fast) ease; -} - -.config-search__tag-option:hover { - background: var(--bg-hover); - color: var(--text); -} - -.config-search__tag-option.active { - background: var(--accent-subtle); - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 34%, transparent); -} - -/* Navigation */ -.config-nav { - flex: 1; - overflow-y: auto; - padding: 6px 8px; - scrollbar-width: none; -} - -.config-nav::-webkit-scrollbar { - display: none; -} - -.config-nav__item { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 8px 12px; - border: 1px solid transparent; - border-radius: var(--radius-md); - background: transparent; - color: var(--muted); - font-size: 12.5px; - font-weight: 500; - text-align: left; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease, - border-color var(--duration-fast) ease; -} - -.config-nav__item:hover { - background: var(--bg-hover); - color: var(--text); -} - -:root[data-theme-mode="light"] .config-nav__item:hover { - background: rgba(0, 0, 0, 0.04); -} - -.config-nav__item.active { - background: var(--accent-subtle); - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 15%, transparent); -} - -.config-nav__icon { - width: 18px; - height: 18px; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - opacity: 0.5; - transition: opacity var(--duration-fast) ease; -} - -.config-nav__item:hover .config-nav__icon, -.config-nav__item.active .config-nav__icon { - opacity: 1; -} - -.config-nav__icon svg { - width: 16px; - height: 16px; - stroke: currentColor; - fill: none; -} - -.config-nav__label { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - /* Mode Toggle */ .config-mode-toggle { display: flex; @@ -688,56 +449,6 @@ line-height: 1.4; } -/* Subsection Nav */ -.config-subnav { - display: flex; - gap: 5px; - padding: 10px 22px 12px; - border-bottom: 1px solid var(--border); - background: var(--bg-accent); - overflow-x: auto; - scrollbar-width: none; -} - -.config-subnav::-webkit-scrollbar { - display: none; -} - -:root[data-theme-mode="light"] .config-subnav { - background: var(--bg-hover); -} - -.config-subnav__item { - border: 1px solid transparent; - border-radius: var(--radius-full); - padding: 5px 13px; - font-size: 11.5px; - font-weight: 600; - color: var(--muted); - background: transparent; - cursor: pointer; - transition: - background var(--duration-fast) ease, - color var(--duration-fast) ease, - border-color var(--duration-fast) ease; - white-space: nowrap; -} - -.config-subnav__item:hover { - color: var(--text); - background: var(--bg-hover); -} - -:root[data-theme-mode="light"] .config-subnav__item:hover { - background: rgba(0, 0, 0, 0.04); -} - -.config-subnav__item.active { - color: var(--accent); - border-color: color-mix(in srgb, var(--accent) 25%, transparent); - background: var(--accent-subtle); -} - /* Content Area */ .config-content { flex: 1; @@ -1891,38 +1602,6 @@ =========================================== */ @media (max-width: 768px) { - .config-sidebar { - border-right: none; - border-bottom: 1px solid var(--border); - } - - .config-sidebar__header { - padding: 14px 16px; - } - - .config-nav { - display: flex; - flex-wrap: nowrap; - gap: 6px; - padding: 10px 14px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - - .config-nav__item { - flex: 0 0 auto; - padding: 9px 14px; - white-space: nowrap; - } - - .config-nav__label { - display: inline; - } - - .config-sidebar__footer { - display: none; - } - .config-actions { flex-wrap: wrap; padding: 14px 16px; @@ -1964,10 +1643,6 @@ padding: 14px 16px; } - .config-subnav { - padding: 10px 16px 12px; - } - .config-content { padding: 16px; } @@ -2012,16 +1687,6 @@ } @media (max-width: 480px) { - .config-nav__icon { - width: 26px; - height: 26px; - font-size: 17px; - } - - .config-nav__label { - display: none; - } - .config-section-card__icon { width: 30px; height: 30px; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index ee71384a0c9..d30cafa1670 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -333,13 +333,13 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 14px 14px 10px; + padding: 14px 14px 6px; flex-shrink: 0; } .sidebar--collapsed .sidebar-header { justify-content: center; - padding: 12px 10px 8px; + padding: 12px 10px 6px; } /* Brand lockup */ @@ -381,7 +381,7 @@ } .sidebar--collapsed .sidebar-nav { - padding: 4px 8px 8px; + padding: 4px 8px; display: grid; gap: 8px; } @@ -523,45 +523,6 @@ opacity: 0; } -/* Legacy .nav kept for mobile breakpoints */ -.nav { - grid-area: nav; - overflow-y: auto; - overflow-x: hidden; - padding: 12px 10px; - background: var(--bg); - border-right: 1px solid var(--border); - scrollbar-width: none; - transition: - width var(--shell-focus-duration) var(--shell-focus-ease), - padding var(--shell-focus-duration) var(--shell-focus-ease), - opacity var(--shell-focus-duration) var(--shell-focus-ease); - min-height: 0; -} - -.nav::-webkit-scrollbar { - display: none; -} - -.shell--chat-focus .nav { - width: 0; - padding: 0; - border-width: 0; - overflow: hidden; - pointer-events: none; - opacity: 0; -} - -.nav--collapsed { - width: 0; - min-width: 0; - padding: 0; - overflow: hidden; - border: none; - opacity: 0; - pointer-events: none; -} - /* Nav collapse toggle */ .nav-collapse-toggle { width: 28px; @@ -629,8 +590,6 @@ display: none; } -/* Nav label */ -.nav-label, .nav-group__label { display: flex; align-items: center; @@ -654,29 +613,24 @@ background var(--duration-fast) ease; } -.nav-label:hover, .nav-group__label:hover { color: var(--text); background: var(--bg-hover); } -.nav-label--static, .nav-group__label--static { cursor: default; } -.nav-label--static:hover, .nav-group__label--static:hover { color: var(--muted); background: transparent; } -.nav-label__text, .nav-group__label-text { flex: 1; } -.nav-label__chevron, .nav-group__chevron { display: inline-flex; align-items: center; @@ -686,7 +640,6 @@ transition: transform var(--duration-fast) ease; } -.nav-label__chevron svg, .nav-group__chevron svg { width: 12px; height: 12px; @@ -697,7 +650,6 @@ stroke-linejoin: round; } -.nav-group--collapsed .nav-label__chevron, .nav-group--collapsed .nav-group__chevron { transform: rotate(-90deg); } @@ -997,18 +949,6 @@ "content"; } - .nav { - position: static; - max-height: none; - display: flex; - gap: 6px; - overflow-x: auto; - border-right: none; - border-bottom: 1px solid var(--border); - padding: 10px 14px; - background: var(--bg); - } - .nav-group { grid-auto-flow: column; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 1d70332911f..b871fe1d440 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -2,60 +2,8 @@ Mobile Layout =========================================== */ -/* Tablet: Horizontal nav */ +/* Tablet and smaller: collapse the left nav into a horizontal rail. */ @media (max-width: 1100px) { - .shell--nav-collapsed .shell-nav { - width: auto; - min-width: 0; - } - - .sidebar--collapsed { - width: auto; - min-width: 0; - flex: 1 1 auto; - } - - .nav { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - gap: 4px; - padding: 10px 14px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - } - - .nav::-webkit-scrollbar { - display: none; - } - - .nav-group { - display: contents; - } - - .nav-group__items { - display: contents; - } - - .nav-label { - display: none; - } - - .nav-group--collapsed .nav-group__items { - display: contents; - } - - .nav-item { - padding: 8px 14px; - font-size: 13px; - border-radius: var(--radius-md); - white-space: nowrap; - flex-shrink: 0; - } -} - -@media (max-width: 768px) { .shell, .shell--nav-collapsed { grid-template-columns: minmax(0, 1fr); @@ -70,12 +18,14 @@ grid-template-rows: var(--shell-topbar-height) 0 minmax(0, 1fr); } + .shell-nav, .shell--nav-collapsed .shell-nav { width: auto; min-width: 0; border-bottom: 1px solid var(--border); } + .sidebar, .sidebar--collapsed { width: auto; min-width: 0; @@ -85,12 +35,18 @@ border-right: none; } + .sidebar-header, .sidebar--collapsed .sidebar-header { justify-content: flex-start; padding: 8px 10px; flex: 0 0 auto; } + .sidebar-brand { + display: none; + } + + .sidebar-nav, .sidebar--collapsed .sidebar-nav { flex: 1 1 auto; display: flex; @@ -104,21 +60,33 @@ scrollbar-width: none; } + .sidebar-nav::-webkit-scrollbar, .sidebar--collapsed .sidebar-nav::-webkit-scrollbar { display: none; } + .nav-group, + .nav-group__items, .sidebar--collapsed .nav-group, .sidebar--collapsed .nav-group__items { display: contents; } - .sidebar--collapsed .nav-group { + .nav-group { margin-bottom: 0; } + .sidebar-nav .nav-group__label { + display: none; + } + + .nav-item, .sidebar--collapsed .nav-item { margin: 0; + padding: 8px 14px; + font-size: 13px; + border-radius: var(--radius-md); + white-space: nowrap; flex: 0 0 auto; } @@ -127,6 +95,7 @@ content: none; } + .sidebar-footer, .sidebar--collapsed .sidebar-footer { display: none; } @@ -134,17 +103,6 @@ /* Mobile-specific styles */ @media (max-width: 600px) { - .shell--nav-collapsed .shell-nav { - width: auto; - min-width: 0; - } - - .sidebar--collapsed { - width: auto; - min-width: 0; - flex: 1 1 auto; - } - .shell { --shell-pad: 8px; --shell-gap: 8px; @@ -193,24 +151,17 @@ display: none; } - /* Nav */ - .nav { - padding: 8px 10px; - gap: 4px; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; + .shell-nav { + border-bottom-width: 0; } - .nav::-webkit-scrollbar { - display: none; + .sidebar-header { + padding: 6px 8px; } - .nav-group { - display: contents; - } - - .nav-label { - display: none; + .sidebar-nav { + gap: 6px; + padding: 6px 8px 6px 0; } .nav-item { @@ -428,10 +379,6 @@ font-size: 13px; } - .nav { - padding: 6px 8px; - } - .nav-item { padding: 6px 8px; font-size: 11px; diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index d9b5f3c7182..207d6d13824 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -85,6 +85,18 @@ describe("control UI routing", () => { expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); + const shellNav = app.querySelector(".shell-nav"); + const sidebarNav = app.querySelector(".sidebar-nav"); + expect(shellNav).not.toBeNull(); + expect(sidebarNav).not.toBeNull(); + if (shellNav) { + expect(getComputedStyle(shellNav).width).toBe(`${window.innerWidth}px`); + } + if (sidebarNav) { + expect(getComputedStyle(sidebarNav).display).toBe("flex"); + expect(getComputedStyle(sidebarNav).flexDirection).toBe("row"); + } + const split = app.querySelector(".chat-split-container"); expect(split).not.toBeNull(); if (split) { @@ -130,6 +142,8 @@ describe("control UI routing", () => { await nextFrame(); } + expect(app.tab).toBe("chat"); + expect(app.connected).toBe(true); const container = app.querySelector(".chat-thread"); expect(container).not.toBeNull(); if (!container) { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 394d950e831..c7f373e51a9 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -138,39 +138,47 @@ function getDeletedMessages(sessionKey: string): DeletedMessages { ); } -// Module-level ephemeral UI state (reset on navigation away) -let sttRecording = false; -let sttInterimText = ""; -let slashMenuOpen = false; -let slashMenuItems: SlashCommandDef[] = []; -let slashMenuIndex = 0; -let slashMenuMode: "command" | "args" = "command"; -let slashMenuCommand: SlashCommandDef | null = null; -let slashMenuArgItems: string[] = []; -let searchOpen = false; -let searchQuery = ""; -let pinnedExpanded = false; +interface ChatEphemeralState { + sttRecording: boolean; + sttInterimText: string; + slashMenuOpen: boolean; + slashMenuItems: SlashCommandDef[]; + slashMenuIndex: number; + slashMenuMode: "command" | "args"; + slashMenuCommand: SlashCommandDef | null; + slashMenuArgItems: string[]; + searchOpen: boolean; + searchQuery: string; + pinnedExpanded: boolean; +} + +function createChatEphemeralState(): ChatEphemeralState { + return { + sttRecording: false, + sttInterimText: "", + slashMenuOpen: false, + slashMenuItems: [], + slashMenuIndex: 0, + slashMenuMode: "command", + slashMenuCommand: null, + slashMenuArgItems: [], + searchOpen: false, + searchQuery: "", + pinnedExpanded: false, + }; +} + +const vs = createChatEphemeralState(); /** - * Reset module-level chat view state when navigating away from chat. - * Prevents STT recording from continuing after a tab switch and clears - * ephemeral search/slash UI that should not survive navigation. + * Reset chat view ephemeral state when navigating away. + * Stops STT recording and clears search/slash UI that should not survive navigation. */ export function resetChatViewState() { - if (sttRecording) { + if (vs.sttRecording) { stopStt(); - sttRecording = false; - sttInterimText = ""; } - slashMenuOpen = false; - slashMenuItems = []; - slashMenuIndex = 0; - slashMenuMode = "command"; - slashMenuCommand = null; - slashMenuArgItems = []; - searchOpen = false; - searchQuery = ""; - pinnedExpanded = false; + Object.assign(vs, createChatEphemeralState()); } export const cleanupChatModuleState = resetChatViewState; @@ -413,10 +421,10 @@ function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof noth } function resetSlashMenuState(): void { - slashMenuMode = "command"; - slashMenuCommand = null; - slashMenuArgItems = []; - slashMenuItems = []; + vs.slashMenuMode = "command"; + vs.slashMenuCommand = null; + vs.slashMenuArgItems = []; + vs.slashMenuItems = []; } function updateSlashMenu(value: string, requestUpdate: () => void): void { @@ -431,17 +439,17 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void { ? cmd.argOptions.filter((opt) => opt.toLowerCase().startsWith(argFilter)) : cmd.argOptions; if (filtered.length > 0) { - slashMenuMode = "args"; - slashMenuCommand = cmd; - slashMenuArgItems = filtered; - slashMenuOpen = true; - slashMenuIndex = 0; - slashMenuItems = []; + vs.slashMenuMode = "args"; + vs.slashMenuCommand = cmd; + vs.slashMenuArgItems = filtered; + vs.slashMenuOpen = true; + vs.slashMenuIndex = 0; + vs.slashMenuItems = []; requestUpdate(); return; } } - slashMenuOpen = false; + vs.slashMenuOpen = false; resetSlashMenuState(); requestUpdate(); return; @@ -451,14 +459,14 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void { const match = value.match(/^\/(\S*)$/); if (match) { const items = getSlashCommandCompletions(match[1]); - slashMenuItems = items; - slashMenuOpen = items.length > 0; - slashMenuIndex = 0; - slashMenuMode = "command"; - slashMenuCommand = null; - slashMenuArgItems = []; + vs.slashMenuItems = items; + vs.slashMenuOpen = items.length > 0; + vs.slashMenuIndex = 0; + vs.slashMenuMode = "command"; + vs.slashMenuCommand = null; + vs.slashMenuArgItems = []; } else { - slashMenuOpen = false; + vs.slashMenuOpen = false; resetSlashMenuState(); } requestUpdate(); @@ -472,17 +480,17 @@ function selectSlashCommand( // Transition to arg picker when the command has fixed options if (cmd.argOptions?.length) { props.onDraftChange(`/${cmd.name} `); - slashMenuMode = "args"; - slashMenuCommand = cmd; - slashMenuArgItems = cmd.argOptions; - slashMenuOpen = true; - slashMenuIndex = 0; - slashMenuItems = []; + vs.slashMenuMode = "args"; + vs.slashMenuCommand = cmd; + vs.slashMenuArgItems = cmd.argOptions; + vs.slashMenuOpen = true; + vs.slashMenuIndex = 0; + vs.slashMenuItems = []; requestUpdate(); return; } - slashMenuOpen = false; + vs.slashMenuOpen = false; resetSlashMenuState(); if (cmd.executeLocal && !cmd.args) { @@ -503,17 +511,17 @@ function tabCompleteSlashCommand( // Tab: fill in the command text without executing if (cmd.argOptions?.length) { props.onDraftChange(`/${cmd.name} `); - slashMenuMode = "args"; - slashMenuCommand = cmd; - slashMenuArgItems = cmd.argOptions; - slashMenuOpen = true; - slashMenuIndex = 0; - slashMenuItems = []; + vs.slashMenuMode = "args"; + vs.slashMenuCommand = cmd; + vs.slashMenuArgItems = cmd.argOptions; + vs.slashMenuOpen = true; + vs.slashMenuIndex = 0; + vs.slashMenuItems = []; requestUpdate(); return; } - slashMenuOpen = false; + vs.slashMenuOpen = false; resetSlashMenuState(); props.onDraftChange(cmd.args ? `/${cmd.name} ` : `/${cmd.name}`); requestUpdate(); @@ -525,8 +533,8 @@ function selectSlashArg( requestUpdate: () => void, execute: boolean, ): void { - const cmdName = slashMenuCommand?.name ?? ""; - slashMenuOpen = false; + const cmdName = vs.slashMenuCommand?.name ?? ""; + vs.slashMenuOpen = false; resetSlashMenuState(); props.onDraftChange(`/${cmdName} ${arg}`); requestUpdate(); @@ -595,7 +603,7 @@ function renderWelcomeState(props: ChatProps): TemplateResult { } function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing { - if (!searchOpen) { + if (!vs.searchOpen) { return nothing; } return html` @@ -604,15 +612,15 @@ function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof not { - searchQuery = (e.target as HTMLInputElement).value; + vs.searchQuery = (e.target as HTMLInputElement).value; requestUpdate(); }} /> ${ - pinnedExpanded + vs.pinnedExpanded ? html`
${entries.map( @@ -680,29 +688,29 @@ function renderSlashMenu( requestUpdate: () => void, props: ChatProps, ): TemplateResult | typeof nothing { - if (!slashMenuOpen) { + if (!vs.slashMenuOpen) { return nothing; } // Arg-picker mode: show options for the selected command - if (slashMenuMode === "args" && slashMenuCommand && slashMenuArgItems.length > 0) { + if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) { return html`
-
/${slashMenuCommand.name} ${slashMenuCommand.description}
- ${slashMenuArgItems.map( +
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
+ ${vs.slashMenuArgItems.map( (arg, i) => html`
selectSlashArg(arg, props, requestUpdate, true)} @mouseenter=${() => { - slashMenuIndex = i; + vs.slashMenuIndex = i; requestUpdate(); }} > - ${slashMenuCommand?.icon ? html`${icons[slashMenuCommand.icon]}` : nothing} + ${vs.slashMenuCommand?.icon ? html`${icons[vs.slashMenuCommand.icon]}` : nothing} ${arg} - /${slashMenuCommand?.name} ${arg} + /${vs.slashMenuCommand?.name} ${arg}
`, )} @@ -718,7 +726,7 @@ function renderSlashMenu( } // Command mode: show grouped commands - if (slashMenuItems.length === 0) { + if (vs.slashMenuItems.length === 0) { return nothing; } @@ -726,8 +734,8 @@ function renderSlashMenu( SlashCommandCategory, Array<{ cmd: SlashCommandDef; globalIdx: number }> >(); - for (let i = 0; i < slashMenuItems.length; i++) { - const cmd = slashMenuItems[i]; + for (let i = 0; i < vs.slashMenuItems.length; i++) { + const cmd = vs.slashMenuItems[i]; const cat = cmd.category ?? "session"; let list = grouped.get(cat); if (!list) { @@ -745,10 +753,10 @@ function renderSlashMenu( ${entries.map( ({ cmd, globalIdx }) => html`
selectSlashCommand(cmd, props, requestUpdate)} @mouseenter=${() => { - slashMenuIndex = globalIdx; + vs.slashMenuIndex = globalIdx; requestUpdate(); }} > @@ -873,9 +881,9 @@ export function renderChat(props: ChatProps) { ` : nothing } - ${isEmpty && !searchOpen ? renderWelcomeState(props) : nothing} + ${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing} ${ - isEmpty && searchOpen + isEmpty && vs.searchOpen ? html`
No matching messages
` @@ -933,30 +941,30 @@ export function renderChat(props: ChatProps) { const handleKeyDown = (e: KeyboardEvent) => { // Slash menu navigation — arg mode - if (slashMenuOpen && slashMenuMode === "args" && slashMenuArgItems.length > 0) { - const len = slashMenuArgItems.length; + if (vs.slashMenuOpen && vs.slashMenuMode === "args" && vs.slashMenuArgItems.length > 0) { + const len = vs.slashMenuArgItems.length; switch (e.key) { case "ArrowDown": e.preventDefault(); - slashMenuIndex = (slashMenuIndex + 1) % len; + vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len; requestUpdate(); return; case "ArrowUp": e.preventDefault(); - slashMenuIndex = (slashMenuIndex - 1 + len) % len; + vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; requestUpdate(); return; case "Tab": e.preventDefault(); - selectSlashArg(slashMenuArgItems[slashMenuIndex], props, requestUpdate, false); + selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, false); return; case "Enter": e.preventDefault(); - selectSlashArg(slashMenuArgItems[slashMenuIndex], props, requestUpdate, true); + selectSlashArg(vs.slashMenuArgItems[vs.slashMenuIndex], props, requestUpdate, true); return; case "Escape": e.preventDefault(); - slashMenuOpen = false; + vs.slashMenuOpen = false; resetSlashMenuState(); requestUpdate(); return; @@ -964,30 +972,30 @@ export function renderChat(props: ChatProps) { } // Slash menu navigation — command mode - if (slashMenuOpen && slashMenuItems.length > 0) { - const len = slashMenuItems.length; + if (vs.slashMenuOpen && vs.slashMenuItems.length > 0) { + const len = vs.slashMenuItems.length; switch (e.key) { case "ArrowDown": e.preventDefault(); - slashMenuIndex = (slashMenuIndex + 1) % len; + vs.slashMenuIndex = (vs.slashMenuIndex + 1) % len; requestUpdate(); return; case "ArrowUp": e.preventDefault(); - slashMenuIndex = (slashMenuIndex - 1 + len) % len; + vs.slashMenuIndex = (vs.slashMenuIndex - 1 + len) % len; requestUpdate(); return; case "Tab": e.preventDefault(); - tabCompleteSlashCommand(slashMenuItems[slashMenuIndex], props, requestUpdate); + tabCompleteSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate); return; case "Enter": e.preventDefault(); - selectSlashCommand(slashMenuItems[slashMenuIndex], props, requestUpdate); + selectSlashCommand(vs.slashMenuItems[vs.slashMenuIndex], props, requestUpdate); return; case "Escape": e.preventDefault(); - slashMenuOpen = false; + vs.slashMenuOpen = false; resetSlashMenuState(); requestUpdate(); return; @@ -1015,9 +1023,9 @@ export function renderChat(props: ChatProps) { // Cmd+F for search if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "f") { e.preventDefault(); - searchOpen = !searchOpen; - if (!searchOpen) { - searchQuery = ""; + vs.searchOpen = !vs.searchOpen; + if (!vs.searchOpen) { + vs.searchQuery = ""; } requestUpdate(); return; @@ -1173,7 +1181,7 @@ export function renderChat(props: ChatProps) { @change=${(e: Event) => handleFileSelect(e, props)} /> - ${sttRecording && sttInterimText ? html`
${sttInterimText}
` : nothing} + ${vs.sttRecording && vs.sttInterimText ? html`
${vs.sttInterimText}
` : nothing} @@ -1204,12 +1212,12 @@ export function renderChat(props: ChatProps) { isSttSupported() ? html` ` : nothing diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index a3ab4f7dd49..877542d178b 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -651,14 +651,27 @@ function renderAppearanceSection(props: ConfigProps) { `; } -let rawRevealed = false; -let envRevealed = false; -let validityDismissed = false; -const revealedSensitivePaths = new Set(); +interface ConfigEphemeralState { + rawRevealed: boolean; + envRevealed: boolean; + validityDismissed: boolean; + revealedSensitivePaths: Set; +} + +function createConfigEphemeralState(): ConfigEphemeralState { + return { + rawRevealed: false, + envRevealed: false, + validityDismissed: false, + revealedSensitivePaths: new Set(), + }; +} + +const cvs = createConfigEphemeralState(); function isSensitivePathRevealed(path: Array): boolean { const key = pathKey(path); - return key ? revealedSensitivePaths.has(key) : false; + return key ? cvs.revealedSensitivePaths.has(key) : false; } function toggleSensitivePathReveal(path: Array) { @@ -666,18 +679,15 @@ function toggleSensitivePathReveal(path: Array) { if (!key) { return; } - if (revealedSensitivePaths.has(key)) { - revealedSensitivePaths.delete(key); + if (cvs.revealedSensitivePaths.has(key)) { + cvs.revealedSensitivePaths.delete(key); } else { - revealedSensitivePaths.add(key); + cvs.revealedSensitivePaths.add(key); } } export function resetConfigViewStateForTests() { - rawRevealed = false; - envRevealed = false; - validityDismissed = false; - revealedSensitivePaths.clear(); + Object.assign(cvs, createConfigEphemeralState()); } export function renderConfig(props: ConfigProps) { @@ -697,7 +707,7 @@ export function renderConfig(props: ConfigProps) { if (formUnsafe && props.formMode === "form") { props.onFormModeChange("raw"); } - const envSensitiveVisible = !props.streamMode && envRevealed; + const envSensitiveVisible = !props.streamMode && cvs.envRevealed; // Build categorised nav from schema - only include sections that exist in the schema const schemaProps = analysis.schema?.properties ?? {}; @@ -916,7 +926,7 @@ export function renderConfig(props: ConfigProps) {
${ - validity === "invalid" && !validityDismissed + validity === "invalid" && !cvs.validityDismissed ? html`
@@ -928,7 +938,7 @@ export function renderConfig(props: ConfigProps) { @@ -1015,7 +1025,7 @@ export function renderConfig(props: ConfigProps) { if (props.streamMode) { return; } - envRevealed = !envRevealed; + cvs.envRevealed = !cvs.envRevealed; props.onRawChange(props.raw); }} > @@ -1083,7 +1093,7 @@ export function renderConfig(props: ConfigProps) { props.streamMode && containsSensitiveKeywords(props.raw); const blurred = (sensitiveCount > 0 || rawHasSensitiveKeywords) && - (props.streamMode || !rawRevealed); + (props.streamMode || !cvs.rawRevealed); const canReveal = (sensitiveCount > 0 || rawHasSensitiveKeywords) && !props.streamMode; return html` @@ -1121,7 +1131,7 @@ export function renderConfig(props: ConfigProps) { if (!canReveal) { return; } - rawRevealed = !rawRevealed; + cvs.rawRevealed = !cvs.rawRevealed; props.onRawChange(props.raw); }} >