refactor(ui): streamline ephemeral state management in chat and config views

- Introduced interfaces for ephemeral state in chat and config views to encapsulate related variables.
- Refactored state management to utilize a single object for better organization and maintainability.
- Removed legacy state variables and updated related functions to reference the new state structure.
- Enhanced readability and consistency across the codebase by standardizing state handling.
This commit is contained in:
Val Alexander
2026-03-11 00:05:07 -05:00
parent 752dc7879a
commit 3a12361fed
7 changed files with 212 additions and 620 deletions

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;

View File

@@ -85,6 +85,18 @@ describe("control UI routing", () => {
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const shellNav = app.querySelector<HTMLElement>(".shell-nav");
const sidebarNav = app.querySelector<HTMLElement>(".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) {

View File

@@ -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
<input
type="text"
placeholder="Search messages..."
.value=${searchQuery}
.value=${vs.searchQuery}
@input=${(e: Event) => {
searchQuery = (e.target as HTMLInputElement).value;
vs.searchQuery = (e.target as HTMLInputElement).value;
requestUpdate();
}}
/>
<button class="btn-ghost" @click=${() => {
searchOpen = false;
searchQuery = "";
vs.searchOpen = false;
vs.searchQuery = "";
requestUpdate();
}}>
${icons.x}
@@ -643,15 +651,15 @@ function renderPinnedSection(
return html`
<div class="agent-chat__pinned">
<button class="agent-chat__pinned-toggle" @click=${() => {
pinnedExpanded = !pinnedExpanded;
vs.pinnedExpanded = !vs.pinnedExpanded;
requestUpdate();
}}>
${icons.bookmark}
${entries.length} pinned
${pinnedExpanded ? icons.chevronDown : icons.chevronRight}
${vs.pinnedExpanded ? icons.chevronDown : icons.chevronRight}
</button>
${
pinnedExpanded
vs.pinnedExpanded
? html`
<div class="agent-chat__pinned-list">
${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`
<div class="slash-menu">
<div class="slash-menu-group">
<div class="slash-menu-group__label">/${slashMenuCommand.name} ${slashMenuCommand.description}</div>
${slashMenuArgItems.map(
<div class="slash-menu-group__label">/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}</div>
${vs.slashMenuArgItems.map(
(arg, i) => html`
<div
class="slash-menu-item ${i === slashMenuIndex ? "slash-menu-item--active" : ""}"
class="slash-menu-item ${i === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
@mouseenter=${() => {
slashMenuIndex = i;
vs.slashMenuIndex = i;
requestUpdate();
}}
>
${slashMenuCommand?.icon ? html`<span class="slash-menu-icon">${icons[slashMenuCommand.icon]}</span>` : nothing}
${vs.slashMenuCommand?.icon ? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>` : nothing}
<span class="slash-menu-name">${arg}</span>
<span class="slash-menu-desc">/${slashMenuCommand?.name} ${arg}</span>
<span class="slash-menu-desc">/${vs.slashMenuCommand?.name} ${arg}</span>
</div>
`,
)}
@@ -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`
<div
class="slash-menu-item ${globalIdx === slashMenuIndex ? "slash-menu-item--active" : ""}"
class="slash-menu-item ${globalIdx === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
@click=${() => 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`
<div class="agent-chat__empty">No matching messages</div>
`
@@ -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`<div class="agent-chat__stt-interim">${sttInterimText}</div>` : nothing}
${vs.sttRecording && vs.sttInterimText ? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>` : nothing}
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
@@ -1183,7 +1191,7 @@ export function renderChat(props: ChatProps) {
@keydown=${handleKeyDown}
@input=${handleInput}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${sttRecording ? "Listening..." : placeholder}
placeholder=${vs.sttRecording ? "Listening..." : placeholder}
rows="1"
></textarea>
@@ -1204,12 +1212,12 @@ export function renderChat(props: ChatProps) {
isSttSupported()
? html`
<button
class="agent-chat__input-btn ${sttRecording ? "agent-chat__input-btn--recording" : ""}"
class="agent-chat__input-btn ${vs.sttRecording ? "agent-chat__input-btn--recording" : ""}"
@click=${() => {
if (sttRecording) {
if (vs.sttRecording) {
stopStt();
sttRecording = false;
sttInterimText = "";
vs.sttRecording = false;
vs.sttInterimText = "";
requestUpdate();
} else {
const started = startStt({
@@ -1218,37 +1226,37 @@ export function renderChat(props: ChatProps) {
const current = getDraft();
const sep = current && !current.endsWith(" ") ? " " : "";
props.onDraftChange(current + sep + text);
sttInterimText = "";
vs.sttInterimText = "";
} else {
sttInterimText = text;
vs.sttInterimText = text;
}
requestUpdate();
},
onStart: () => {
sttRecording = true;
vs.sttRecording = true;
requestUpdate();
},
onEnd: () => {
sttRecording = false;
sttInterimText = "";
vs.sttRecording = false;
vs.sttInterimText = "";
requestUpdate();
},
onError: () => {
sttRecording = false;
sttInterimText = "";
vs.sttRecording = false;
vs.sttInterimText = "";
requestUpdate();
},
});
if (started) {
sttRecording = true;
vs.sttRecording = true;
requestUpdate();
}
}
}}
title=${sttRecording ? "Stop recording" : "Voice input"}
title=${vs.sttRecording ? "Stop recording" : "Voice input"}
?disabled=${!props.connected}
>
${sttRecording ? icons.micOff : icons.mic}
${vs.sttRecording ? icons.micOff : icons.mic}
</button>
`
: nothing

View File

@@ -651,14 +651,27 @@ function renderAppearanceSection(props: ConfigProps) {
`;
}
let rawRevealed = false;
let envRevealed = false;
let validityDismissed = false;
const revealedSensitivePaths = new Set<string>();
interface ConfigEphemeralState {
rawRevealed: boolean;
envRevealed: boolean;
validityDismissed: boolean;
revealedSensitivePaths: Set<string>;
}
function createConfigEphemeralState(): ConfigEphemeralState {
return {
rawRevealed: false,
envRevealed: false,
validityDismissed: false,
revealedSensitivePaths: new Set(),
};
}
const cvs = createConfigEphemeralState();
function isSensitivePathRevealed(path: Array<string | number>): boolean {
const key = pathKey(path);
return key ? revealedSensitivePaths.has(key) : false;
return key ? cvs.revealedSensitivePaths.has(key) : false;
}
function toggleSensitivePathReveal(path: Array<string | number>) {
@@ -666,18 +679,15 @@ function toggleSensitivePathReveal(path: Array<string | number>) {
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) {
</div>
${
validity === "invalid" && !validityDismissed
validity === "invalid" && !cvs.validityDismissed
? html`
<div class="config-validity-warning">
<svg class="config-validity-warning__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
@@ -928,7 +938,7 @@ export function renderConfig(props: ConfigProps) {
<button
class="btn btn--sm"
@click=${() => {
validityDismissed = true;
cvs.validityDismissed = true;
props.onRawChange(props.raw);
}}
>Don't remind again</button>
@@ -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);
}}
>