diff --git a/apps/web/package.json b/apps/web/package.json index f7a21240..c0634631 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "bun test --isolate && bun run test:interaction", - "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/features/Sidebar.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" + "test:interaction": "bun test --isolate ./src/routes/root-layout.interaction.tsx ./src/routes/project-todos.interaction.tsx ./src/context/workbench-layout.interaction.tsx ./src/components/primitives/IconAction.interaction.tsx ./src/components/primitives/StatusTransition.interaction.tsx ./src/components/features/ResizeHandle.interaction.tsx ./src/components/features/TodoProgressButton.interaction.tsx ./src/components/features/DiffTab.interaction.tsx ./src/components/features/ContextInspector.interaction.tsx ./src/components/features/InspectorToggleButton.interaction.tsx ./src/components/features/SidebarToggleButton.interaction.tsx ./src/components/features/SettingsDialog.interaction.tsx ./src/components/features/EditAutomationDialog.interaction.tsx ./src/components/features/SessionComposerDock.interaction.tsx ./src/components/features/ComposerQueueList.interaction.tsx ./src/components/features/SessionGoalSummaryRow.interaction.tsx ./src/components/features/HitlBell.interaction.tsx ./src/components/features/AddProjectModal.interaction.tsx ./src/components/features/Sidebar.interaction.tsx ./src/components/composite/ExecutionWorkstream.interaction.tsx ./src/components/composite/ToolCard.interaction.tsx" }, "dependencies": { "@archcode/protocol": "workspace:*", diff --git a/apps/web/src/components/features/ChatHeader.test.tsx b/apps/web/src/components/features/ChatHeader.test.tsx index 5273bded..6b18ef7c 100644 --- a/apps/web/src/components/features/ChatHeader.test.tsx +++ b/apps/web/src/components/features/ChatHeader.test.tsx @@ -11,6 +11,7 @@ import { __resetWebSessionStoresForTest, getWebSessionStore, } from "../../store/session-store"; +import { WorkbenchLayoutProvider } from "../../context/workbench-layout"; let dom: JSDOM; let root: Root; @@ -83,12 +84,14 @@ describe("ChatHeader", () => { }); await act(async () => root.render( - {}} - />, + + {}} + /> + , ), ); const status = container.querySelector( @@ -96,5 +99,7 @@ describe("ChatHeader", () => { ); expect(status?.textContent).toContain("Needs you"); expect(status?.getAttribute("data-product-status")).toBe("needs_you"); + expect(container.querySelector('header > button[aria-label="Collapse project sidebar"]')).not.toBeNull(); + expect(container.querySelector('header button[aria-label="Expand context inspector"]')).not.toBeNull(); }); }); diff --git a/apps/web/src/components/features/ChatHeader.tsx b/apps/web/src/components/features/ChatHeader.tsx index 33c7eea7..1fc46f7e 100644 --- a/apps/web/src/components/features/ChatHeader.tsx +++ b/apps/web/src/components/features/ChatHeader.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import { useSessionStore } from "../../store/session-store"; import { TodoProgressButton } from "./TodoProgressButton"; import { InspectorToggleButton } from "./InspectorToggleButton"; +import { SidebarToggleButton } from "./SidebarToggleButton"; import { executionVisualKind, presentExecutionStatus, @@ -65,6 +66,7 @@ export function ChatHeader({ return (
+

@@ -147,8 +149,6 @@ export function ChatHeader({

diff --git a/apps/web/src/components/features/ChatInput.tsx b/apps/web/src/components/features/ChatInput.tsx index a0a90ddb..dd66e30a 100644 --- a/apps/web/src/components/features/ChatInput.tsx +++ b/apps/web/src/components/features/ChatInput.tsx @@ -14,7 +14,6 @@ import { getWebSessionStore, useSessionStore, } from "../../store/session-store"; -import { useSettingsModal } from "../../context/settings-modal"; import { ModelPicker } from "./ModelPicker"; import { coherentModelRuntime } from "../../lib/model-runtime-coherence"; import { createClientUuid } from "../../lib/client-uuid"; @@ -109,7 +108,6 @@ export function ChatInput({ nextModelSelection, isModelRuntimeFetching, ); - const { openSettingsModal } = useSettingsModal(); const postMessage = usePostMessage(); const patchModelSelection = usePatchSessionModelSelection(); const stopSession = useStopSessionFamily(); @@ -583,7 +581,6 @@ export function ChatInput({ next={nextModelSelection} active={activeModelBinding} onSelect={selectModel} - onManageModels={() => openSettingsModal("models")} disabled={patchModelSelection.isPending} /> : Loading model…} diff --git a/apps/web/src/components/features/ContextInspector.tsx b/apps/web/src/components/features/ContextInspector.tsx index 105475d9..3f9dcb1a 100644 --- a/apps/web/src/components/features/ContextInspector.tsx +++ b/apps/web/src/components/features/ContextInspector.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from "react"; -import { PanelRightClose } from "lucide-react"; import { useSearchParams } from "react-router-dom"; import type { InspectorKind } from "../../lib/workbench-layout"; import { SessionInspector, type SessionInspectorTab } from "./context-inspector/SessionInspector"; @@ -18,26 +17,22 @@ const SESSION_TABS = [ export function ContextInspector({ kind, id = "context-inspector", - onCollapse, }: { kind: InspectorKind; id?: string; - onCollapse?: () => void; }) { - return } />; + return } />; } function InspectorShell({ kind, id, tabs, - onCollapse, renderPanel, }: { kind: InspectorKind; id: string; tabs: readonly [InspectorTab, ...InspectorTab[]]; - onCollapse?: () => void; renderPanel: (activeTab: T) => ReactNode; }) { const [searchParams, setSearchParams] = useSearchParams(); @@ -62,19 +57,6 @@ function InspectorShell({
Context inspector
{kind}
- {onCollapse && ( - - )}
{tabs.map((tab, index) => ( diff --git a/apps/web/src/components/features/InspectorToggleButton.interaction.tsx b/apps/web/src/components/features/InspectorToggleButton.interaction.tsx index cb5877ed..65eee2f4 100644 --- a/apps/web/src/components/features/InspectorToggleButton.interaction.tsx +++ b/apps/web/src/components/features/InspectorToggleButton.interaction.tsx @@ -42,7 +42,9 @@ describe("InspectorToggleButton", () => { let button = container.querySelector("button")!; expect(button.getAttribute("aria-label")).toBe("Collapse context inspector"); expect(button.getAttribute("data-state")).toBe("expanded"); - expect(button.className).toContain("max-[799px]:hidden"); + expect(button.className).toContain("h-8"); + expect(button.className).toContain("max-[760px]:hidden"); + expect(button.className).not.toContain("border-border-default"); expect(button.querySelector(".lucide-panel-right-close")).not.toBeNull(); await act(async () => root.render( {}} />)); diff --git a/apps/web/src/components/features/InspectorToggleButton.tsx b/apps/web/src/components/features/InspectorToggleButton.tsx index e4ba00ef..26c312b4 100644 --- a/apps/web/src/components/features/InspectorToggleButton.tsx +++ b/apps/web/src/components/features/InspectorToggleButton.tsx @@ -1,10 +1,10 @@ -import { PanelRightClose, PanelRightOpen } from "lucide-react"; +import { PanelToggleButton, WORKBENCH_PANEL_TOGGLE_CLASS } from "./PanelToggleButton"; export function InspectorToggleButton({ expanded, onToggle, - className = "flex h-8 w-8 shrink-0 items-center justify-center rounded-sm border border-border-default text-text-tertiary transition-colors hover:bg-bg-hover hover:text-text-primary max-[799px]:hidden", - iconSize = 15, + className = WORKBENCH_PANEL_TOGGLE_CLASS, + iconSize = 16, }: { expanded: boolean; onToggle: () => void; @@ -13,19 +13,14 @@ export function InspectorToggleButton({ }) { const label = expanded ? "Collapse context inspector" : "Expand context inspector"; return ( - + iconSize={iconSize} + onToggle={onToggle} + /> ); } diff --git a/apps/web/src/components/features/ModelPicker.test.tsx b/apps/web/src/components/features/ModelPicker.test.tsx index 04767025..0c114717 100644 --- a/apps/web/src/components/features/ModelPicker.test.tsx +++ b/apps/web/src/components/features/ModelPicker.test.tsx @@ -2,10 +2,13 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { JSDOM } from "jsdom"; -import { - ModelPicker, -} from "./ModelPicker"; -import type { ExecutionModelBindingSummary, ModelRuntimeCatalog, RequestedModelSelection, SessionNextModelSelection } from "@archcode/protocol"; +import { ModelPicker } from "./ModelPicker"; +import type { + ExecutionModelBindingSummary, + ModelRuntimeCatalog, + RequestedModelSelection, + SessionNextModelSelection, +} from "@archcode/protocol"; let dom: JSDOM; let root: Root; @@ -58,7 +61,6 @@ const catalog: ModelRuntimeCatalog = { }; const onSelect = mock((_selection: RequestedModelSelection) => {}); -const onManageModels = mock(() => {}); beforeEach(() => { dom = new JSDOM("
", { url: "http://localhost" }); @@ -83,7 +85,6 @@ beforeEach(() => { container = document.querySelector("#root") as HTMLDivElement; root = createRoot(container); onSelect.mockClear(); - onManageModels.mockClear(); }); afterEach(() => { @@ -98,7 +99,6 @@ function renderPicker(props: Partial[0]> = {}) { next={next} active={active} onSelect={onSelect} - onManageModels={onManageModels} {...props} />, )); @@ -109,146 +109,402 @@ function click(element: Element | null): void { act(() => element.click()); } -function openPicker(): void { +function openModelPicker(): void { click(container.querySelector('[data-testid="model-picker-trigger"]')); } +function openVariantPicker(): void { + click(container.querySelector('[data-testid="variant-picker-trigger"]')); +} + +function changeSearch(value: string): void { + const search = container.querySelector('input[type="search"]'); + if (!(search instanceof dom.window.HTMLInputElement)) throw new Error("Missing search input"); + + act(() => { + const previous = search.value; + const valueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(search, value); + (search as unknown as { _valueTracker?: { setValue(value: string): void } })._valueTracker?.setValue(previous); + const propsKey = Object.keys(search).find((key) => key.startsWith("__reactProps$")); + const props = propsKey + ? (search as unknown as Record void }>)[propsKey] + : undefined; + if (props?.onChange) props.onChange({ target: search }); + else search.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + }); +} + +function profileDefaultState(): SessionNextModelSelection { + const resolved: ExecutionModelBindingSummary = { + ...nextBinding, + selection: { model: "openai:gpt-5", variant: "fast" }, + providerId: "openai", + providerDisplayName: "OpenAI", + modelId: "gpt-5", + modelDisplayName: "GPT-5", + resolution: "profile_default", + }; + return { + requested: { mode: "profile_default", selection: resolved.selection }, + resolved, + }; +} + +function largeCatalogState(): ModelRuntimeCatalog { + return { + ...catalog, + providers: [ + ...catalog.providers, + { + id: "local", + displayName: "Local", + models: Array.from({ length: 8 }, (_, index) => ({ + id: `model-${index + 1}`, + qualifiedId: `local:model-${index + 1}`, + displayName: `Local Model ${index + 1}`, + variants: index === 7 ? ["deep"] : [], + })), + }, + ], + }; +} + describe("ModelPicker", () => { test("renders only a neutral refresh state for mismatched catalog and next revisions", () => { renderPicker({ catalog: { ...catalog, revision: "revision-3" } }); expect(container.querySelector('[data-testid="model-picker-refreshing"]')?.textContent).toBe("Refreshing model configuration…"); expect(container.querySelector('[data-testid="model-picker-trigger"]')).toBeNull(); + expect(container.querySelector('[data-testid="variant-picker-trigger"]')).toBeNull(); }); - test("shows controlled next and active bindings in an upward, narrow-safe popover", () => { + test("renders two independent ghost selectors without segmented chrome", () => { renderPicker(); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).toContain("Next: Claude Sonnet · deep"); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).not.toContain("Override"); + const picker = container.querySelector('[data-testid="model-picker"]'); + const modelTrigger = container.querySelector('[data-testid="model-picker-trigger"]'); + const variantTrigger = container.querySelector('[data-testid="variant-picker-trigger"]'); + + expect(picker?.className).toContain("gap-0.5"); + expect(picker?.className).not.toContain("border"); + expect(picker?.className).not.toContain("bg-bg-base"); + expect(modelTrigger?.textContent).toContain("Claude Sonnet"); + expect(modelTrigger?.textContent).not.toContain("deep"); + expect(variantTrigger?.textContent).toContain("deep"); + expect(variantTrigger?.className).not.toContain("font-mono"); + expect(modelTrigger?.getAttribute("aria-label")).toBe( + "Choose next model, Claude Sonnet; running GPT-5", + ); + expect(variantTrigger?.getAttribute("aria-label")).toBe( + "Choose next variant for Claude Sonnet, deep", + ); + }); + + test("uses compact anchored menus and omits routine audit and management chrome", () => { + renderPicker(); + openModelPicker(); - openPicker(); const popover = container.querySelector('[data-testid="model-picker-popover"]'); - expect(popover?.className).toContain("bottom-[calc(100%+8px)]"); - expect(popover?.className).toContain("max-[520px]:fixed"); - expect(popover?.className).toContain("max-[520px]:right-3"); + expect(popover?.className).toContain("bottom-[calc(100%+6px)]"); + expect(popover?.className).toContain("min-[761px]:left-0"); + expect(popover?.className).toContain("w-[288px]"); + expect(popover?.className).toContain("[@media(max-width:520px)]:fixed"); + expect(popover?.className).toContain("[@media(max-width:520px)]:right-3"); expect(popover?.className).toContain( - "max-[520px]:w-[min(360px,calc(100vw-72px))]", + "[@media(max-width:520px)]:w-[min(288px,calc(100vw-72px))]", ); - expect(container.textContent).toContain("Running withGPT-5"); - expect(container.textContent).toContain("NextClaude Sonnet · deep"); - expect(container.querySelector('[data-testid="model-picker-next-mode"]')?.textContent).toBe("· Override"); + expect(container.querySelector('[data-testid="model-picker-selection-summary"]')).toBeNull(); + expect(container.querySelector('input[type="search"]')).toBeNull(); + expect(popover?.textContent).not.toContain("Running"); + expect(popover?.textContent).not.toContain("Next"); + expect(popover?.textContent).not.toContain("Manage models"); + expect(popover?.textContent).not.toContain("anthropic:claude-sonnet"); }); - test("searches provider, model, and variant while preserving Provider groups", () => { + test("announces an accepted next-selection transition without persistent audit chrome", () => { renderPicker(); - openPicker(); - const search = container.querySelector('input[type="search"]'); - if (!(search instanceof dom.window.HTMLInputElement)) throw new Error("Missing search input"); + expect(container.querySelector('[data-testid="model-picker-next-notice"]')).toBeNull(); - act(() => { - const previous = search.value; - const valueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set; - valueSetter?.call(search, "deep"); - (search as unknown as { _valueTracker?: { setValue(value: string): void } })._valueTracker?.setValue(previous); - const propsKey = Object.keys(search).find((key) => key.startsWith("__reactProps$")); - const props = propsKey ? (search as unknown as Record void }>)[propsKey] : undefined; - if (props?.onChange) props.onChange({ target: search }); - else search.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + const changedBinding: ExecutionModelBindingSummary = { + ...nextBinding, + selection: { model: "anthropic:claude-sonnet", variant: "fast" }, + }; + renderPicker({ + next: { + requested: { mode: "session_override", selection: changedBinding.selection }, + resolved: changedBinding, + }, }); - expect(container.querySelector('section[aria-label="Anthropic"]')).not.toBeNull(); - expect(container.querySelector('section[aria-label="OpenAI"]')).toBeNull(); - expect(container.querySelector('button[data-model="anthropic:claude-sonnet"][data-variant="deep"]')).not.toBeNull(); - expect(container.querySelector('button[data-model="anthropic:claude-sonnet"][data-variant="fast"]')).toBeNull(); + expect(container.querySelector('[data-testid="model-picker-next-notice"]')?.textContent).toBe( + "Applies to the next execution", + ); + }); + + test("lists every model once and marks the effective model regardless of source", () => { + renderPicker(); + openModelPicker(); + + expect(container.querySelectorAll("button[data-model]").length).toBe(2); + expect(container.querySelectorAll('button[data-model="anthropic:claude-sonnet"]').length).toBe(1); + expect(container.querySelector('button[data-model="anthropic:claude-sonnet"] [aria-label="Selected"]')).not.toBeNull(); + expect(container.querySelector("section")).toBeNull(); }); - test("reports an override without mutating the controlled trigger", () => { + test("reveals search only for a larger catalog and filters models rather than Variants", () => { + renderPicker({ catalog: largeCatalogState() }); + openModelPicker(); + + expect((container.querySelector('input[type="search"]') as HTMLInputElement).placeholder).toBe("Search models…"); + changeSearch("Local Model 8"); + expect(container.querySelectorAll("button[data-model]").length).toBe(1); + expect(container.querySelector('button[data-model="local:model-8"]')).not.toBeNull(); + + changeSearch("deep"); + expect(container.querySelectorAll("button[data-model]").length).toBe(0); + expect(container.textContent).toContain("No models match “deep”"); + }); + + test("selects a different model with its model default", () => { renderPicker(); - openPicker(); - click(container.querySelector('button[data-model="openai:gpt-5"][data-variant="fast"]')); + openModelPicker(); + click(container.querySelector('button[data-model="openai:gpt-5"]')); - expect(onSelect).toHaveBeenCalledWith({ mode: "session_override", selection: { model: "openai:gpt-5", variant: "fast" } }); + expect(onSelect).toHaveBeenCalledWith({ + mode: "session_override", + selection: { model: "openai:gpt-5" }, + }); expect(container.querySelector('[data-testid="model-picker-popover"]')).toBeNull(); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).toContain("Next: Claude Sonnet · deep"); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).not.toContain("Override"); + }); + + test("treats the effective model as a no-op instead of changing its source", () => { + renderPicker({ next: profileDefaultState(), active: undefined }); + openModelPicker(); + click(container.querySelector('button[data-model="openai:gpt-5"]')); + + expect(onSelect).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="model-picker-popover"]')).toBeNull(); + }); + + test("shows only Default and the current model Variants in the compact Variant menu", () => { + renderPicker(); + openVariantPicker(); + + const popover = container.querySelector('[data-testid="variant-picker-popover"]'); + expect(popover?.className).toContain("right-0"); + expect(popover?.className).toContain("w-[152px]"); + expect(popover?.className).toContain("[@media(max-width:520px)]:w-[min(152px,calc(100vw-72px))]"); + expect(popover?.textContent).toBe("Defaultfastdeep"); + expect(popover?.textContent).not.toContain("Model settings"); + expect(popover?.textContent).not.toContain("Principal"); + expect(container.querySelectorAll("button[data-variant]").length).toBe(3); + expect(container.querySelector('button[data-variant="deep"] [aria-label="Selected"]')).not.toBeNull(); + expect(container.querySelector('button[data-variant="deep"]')?.className).not.toContain("font-mono"); + }); + + test("preserves the model when selecting a different Variant", () => { + renderPicker(); + openVariantPicker(); + click(container.querySelector('button[data-variant="fast"]')); + + expect(onSelect).toHaveBeenCalledWith({ + mode: "session_override", + selection: { model: "anthropic:claude-sonnet", variant: "fast" }, + }); + expect(container.querySelector('[data-testid="variant-picker-popover"]')).toBeNull(); + }); + + test("selects the current model default by omitting the Variant", () => { + renderPicker(); + openVariantPicker(); + click(container.querySelector('button[data-variant=""]')); + + expect(onSelect).toHaveBeenCalledWith({ + mode: "session_override", + selection: { model: "anthropic:claude-sonnet" }, + }); + }); + + test("treats the effective Variant as a no-op instead of changing its source", () => { + renderPicker({ next: profileDefaultState(), active: undefined }); + openVariantPicker(); + click(container.querySelector('button[data-variant="fast"]')); + + expect(onSelect).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="variant-picker-popover"]')).toBeNull(); }); test("keeps an internal option pointerdown inside the picker", () => { renderPicker(); - openPicker(); - const option = container.querySelector('button[data-model="openai:gpt-5"][data-variant="fast"]'); - if (!(option instanceof dom.window.HTMLButtonElement)) throw new Error("Missing model option"); + openVariantPicker(); + const option = container.querySelector('button[data-variant="fast"]'); + if (!(option instanceof dom.window.HTMLButtonElement)) throw new Error("Missing Variant option"); act(() => { option.dispatchEvent(new dom.window.PointerEvent("pointerdown", { bubbles: true })); option.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); }); - expect(onSelect).toHaveBeenCalledWith({ mode: "session_override", selection: { model: "openai:gpt-5", variant: "fast" } }); + expect(onSelect).toHaveBeenCalledWith({ + mode: "session_override", + selection: { model: "anthropic:claude-sonnet", variant: "fast" }, + }); }); - test("closes on pointerdown outside the picker", () => { + test("closes either picker on pointerdown outside the control group", () => { renderPicker(); - openPicker(); + openVariantPicker(); const outside = document.createElement("button"); document.body.append(outside); act(() => outside.dispatchEvent(new dom.window.PointerEvent("pointerdown", { bubbles: true }))); - expect(container.querySelector('[data-testid="model-picker-popover"]')).toBeNull(); + expect(container.querySelector('[data-testid="variant-picker-popover"]')).toBeNull(); outside.remove(); }); - test("offers the Principal profile with its resolved model", () => { + test("offers a single contextual path back to the Principal default", () => { renderPicker(); - openPicker(); - expect(container.querySelector('[data-testid="model-picker-principal-profile"]')?.textContent).toContain("GPT-5 · fast"); - click(container.querySelector('[data-testid="model-picker-principal-profile"]')); - expect(onSelect).toHaveBeenCalledWith({ mode: "profile_default", selection: { model: "openai:gpt-5", variant: "fast" } }); - }); + openModelPicker(); - test("keeps requested mode in the popover and exactly one selected option", () => { - const defaultBinding = { - ...active, + const reset = container.querySelector('[data-testid="model-picker-principal-profile"]'); + expect(reset?.textContent).toBe("Use Principal default"); + click(reset); + expect(onSelect).toHaveBeenCalledWith({ + mode: "profile_default", selection: { model: "openai:gpt-5", variant: "fast" }, - resolution: "profile_default" as const, - modelRuntimeRevision: catalog.revision, + }); + + renderPicker({ next: profileDefaultState(), active: undefined }); + openModelPicker(); + expect(container.querySelector('[data-testid="model-picker-principal-profile"]')).toBeNull(); + expect(container.querySelector('button[data-model="openai:gpt-5"] [aria-label="Selected"]')).not.toBeNull(); + }); + + test("shows provider hints only when model display names collide", () => { + const duplicateCatalog: ModelRuntimeCatalog = { + ...catalog, + providers: catalog.providers.map((provider) => ({ + ...provider, + models: [ + ...provider.models, + { + id: "shared", + qualifiedId: `${provider.id}:shared`, + displayName: "Shared Model", + variants: [], + }, + ], + })), }; - renderPicker({ next: { requested: { mode: "profile_default", selection: defaultBinding.selection }, resolved: defaultBinding }, active: undefined }); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).toContain("GPT-5 · fast"); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).not.toContain("Principal profile"); - openPicker(); - expect(container.querySelector('[data-testid="model-picker-next-mode"]')?.textContent).toBe("· Principal profile"); - expect(container.querySelector('[data-testid="model-picker-principal-profile"] [aria-label="Selected"]')).not.toBeNull(); - expect(container.querySelector('button[data-model="openai:gpt-5"][data-variant="fast"] [aria-label="Selected"]')).toBeNull(); - act(() => document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); + renderPicker({ catalog: duplicateCatalog }); + openModelPicker(); - renderPicker({ next: { requested: { mode: "session_override", selection: defaultBinding.selection }, resolved: { ...defaultBinding, resolution: "session_override" } }, active: undefined }); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).toContain("GPT-5 · fast"); - expect(container.querySelector('[data-testid="model-picker-trigger"]')?.textContent).not.toContain("Override"); - openPicker(); - expect(container.querySelector('[data-testid="model-picker-next-mode"]')?.textContent).toBe("· Override"); - expect(container.querySelector('[data-testid="model-picker-principal-profile"] [aria-label="Selected"]')).toBeNull(); - expect(container.querySelector('button[data-model="openai:gpt-5"][data-variant="fast"] [aria-label="Selected"]')).not.toBeNull(); + const duplicates = container.querySelectorAll('button[data-model$=":shared"]'); + expect(duplicates.length).toBe(2); + expect(duplicates[0]?.textContent).toContain("OpenAI"); + expect(duplicates[1]?.textContent).toContain("Anthropic"); + expect(container.querySelector('button[data-model="openai:gpt-5"]')?.textContent).toBe("GPT-5"); }); - test("opens Models management and closes the picker", () => { + test("supports Arrow navigation within either compact menu", () => { renderPicker(); - openPicker(); - click(Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Manage models")) ?? null); - expect(onManageModels).toHaveBeenCalledTimes(1); + openVariantPicker(); + const selected = container.querySelector('button[data-variant="deep"]'); + if (!(selected instanceof dom.window.HTMLButtonElement)) throw new Error("Missing selected Variant"); + selected.focus(); + + act(() => selected.dispatchEvent(new dom.window.KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + }))); + + expect(document.activeElement?.getAttribute("data-variant")).toBe(""); + }); + + test("preserves search caret keys and restores model-trigger focus after keyboard navigation", async () => { + renderPicker({ + catalog: largeCatalogState(), + next: profileDefaultState(), + active: undefined, + }); + openModelPicker(); + await act(async () => await Promise.resolve()); + + const search = container.querySelector('input[type="search"]'); + if (!(search instanceof dom.window.HTMLInputElement)) throw new Error("Missing search input"); + expect(document.activeElement).toBe(search); + + for (const key of ["Home", "End"]) { + const event = new dom.window.KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + act(() => search.dispatchEvent(event)); + expect(event.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(search); + } + + act(() => search.dispatchEvent(new dom.window.KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + }))); + expect(document.activeElement?.getAttribute("data-model")).toBe("openai:gpt-5"); + + act(() => document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + }))); + await act(async () => await Promise.resolve()); expect(container.querySelector('[data-testid="model-picker-popover"]')).toBeNull(); + expect(document.activeElement).toBe(container.querySelector('[data-testid="model-picker-trigger"]')); }); - test("closes on Escape and disables opening when unavailable", () => { + test("hides the Variant selector when the current model has no configured Variants", () => { + const catalogWithoutVariants: ModelRuntimeCatalog = { + ...catalog, + providers: [{ + id: "anthropic", + displayName: "Anthropic", + models: [{ + id: "claude-sonnet", + qualifiedId: "anthropic:claude-sonnet", + displayName: "Claude Sonnet", + variants: [], + }], + }], + }; + const baseBinding = { + ...nextBinding, + selection: { model: "anthropic:claude-sonnet" }, + }; + renderPicker({ + catalog: catalogWithoutVariants, + next: { + requested: { mode: "session_override", selection: baseBinding.selection }, + resolved: baseBinding, + }, + }); + + expect(container.querySelector('[data-testid="model-picker-trigger"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="variant-picker-trigger"]')).toBeNull(); + }); + + test("closes on Escape and disables both selectors when unavailable", () => { renderPicker(); - openPicker(); + openVariantPicker(); act(() => document.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); - expect(container.querySelector('[data-testid="model-picker-popover"]')).toBeNull(); + expect(container.querySelector('[data-testid="variant-picker-popover"]')).toBeNull(); renderPicker({ disabled: true }); - const trigger = container.querySelector('[data-testid="model-picker-trigger"]'); - expect((trigger as HTMLButtonElement).disabled).toBe(true); - click(trigger); + const modelTrigger = container.querySelector('[data-testid="model-picker-trigger"]'); + const variantTrigger = container.querySelector('[data-testid="variant-picker-trigger"]'); + expect((modelTrigger as HTMLButtonElement).disabled).toBe(true); + expect((variantTrigger as HTMLButtonElement).disabled).toBe(true); + click(modelTrigger); + click(variantTrigger); expect(container.querySelector('[data-testid="model-picker-popover"]')).toBeNull(); + expect(container.querySelector('[data-testid="variant-picker-popover"]')).toBeNull(); }); }); diff --git a/apps/web/src/components/features/ModelPicker.tsx b/apps/web/src/components/features/ModelPicker.tsx index 31130464..e1b81a40 100644 --- a/apps/web/src/components/features/ModelPicker.tsx +++ b/apps/web/src/components/features/ModelPicker.tsx @@ -4,7 +4,6 @@ import type { ExecutionModelBindingSummary, ModelRuntimeCatalog, ModelRuntimeModelDescriptor, - ModelSelectionRef, RequestedModelSelection, SessionNextModelSelection, } from "@archcode/protocol"; @@ -14,31 +13,57 @@ export interface ModelPickerProps { next: SessionNextModelSelection; active?: ExecutionModelBindingSummary; onSelect: (selection: RequestedModelSelection) => void; - onManageModels: () => void; disabled?: boolean; } -function sameSelection(left: ModelSelectionRef, right: ModelSelectionRef): boolean { - return left.model === right.model && left.variant === right.variant; -} +type OpenPicker = "model" | "variant"; -function bindingLabel(binding: ExecutionModelBindingSummary): string { - const model = binding.modelDisplayName || binding.modelId; - return binding.selection.variant ? `${model} · ${binding.selection.variant}` : model; -} +type CatalogModelEntry = { + model: ModelRuntimeModelDescriptor; + providerId: string; + providerDisplayName: string; +}; + +const MODEL_SEARCH_THRESHOLD = 8; -function modeLabel(selection: RequestedModelSelection): string { - return selection.mode === "profile_default" ? "Principal profile" : "Override"; +function findCatalogModel( + catalog: ModelRuntimeCatalog, + qualifiedId: string, +): ModelRuntimeModelDescriptor | undefined { + return catalog.providers + .flatMap((provider) => provider.models) + .find((model) => model.qualifiedId === qualifiedId); } -function catalogSelectionLabel(model: ModelRuntimeModelDescriptor, variant?: string): string { - return variant ? `${model.displayName} · ${variant}` : model.displayName; +function pickerPopoverClass(width: string, mobileWidth: string, alignment: string): string { + return `absolute bottom-[calc(100%+6px)] ${alignment} z-50 flex max-h-[min(70vh,384px)] ${width} flex-col overflow-hidden rounded-lg border border-border-default bg-bg-overlay p-1 shadow-md animate-overlay-enter motion-reduce:animate-none [@media(max-width:520px)]:fixed [@media(max-width:520px)]:bottom-[72px] [@media(max-width:520px)]:left-auto [@media(max-width:520px)]:right-3 ${mobileWidth}`; } -function catalogRefLabel(catalog: ModelRuntimeCatalog, selection: ModelSelectionRef): string { - const model = catalog.providers.flatMap((provider) => provider.models).find((candidate) => candidate.qualifiedId === selection.model); - const displayName = model?.displayName ?? selection.model; - return selection.variant ? `${displayName} · ${selection.variant}` : displayName; +function movePickerFocus( + event: React.KeyboardEvent, + root: HTMLDivElement | null, +): void { + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + const fromOption = event.target instanceof HTMLElement + && event.target.hasAttribute("data-picker-option"); + if (!fromOption && (event.key === "Home" || event.key === "End")) return; + const options = Array.from( + root?.querySelectorAll("[data-picker-option]:not(:disabled)") ?? [], + ); + if (options.length === 0) return; + + const currentIndex = options.indexOf(event.target as HTMLButtonElement); + let nextIndex: number; + if (event.key === "Home") nextIndex = 0; + else if (event.key === "End") nextIndex = options.length - 1; + else if (event.key === "ArrowUp") { + nextIndex = currentIndex <= 0 ? options.length - 1 : currentIndex - 1; + } else { + nextIndex = currentIndex < 0 || currentIndex === options.length - 1 ? 0 : currentIndex + 1; + } + + event.preventDefault(); + options[nextIndex]?.focus(); } export function ModelPicker({ @@ -46,187 +71,356 @@ export function ModelPicker({ next, active, onSelect, - onManageModels, disabled = false, }: ModelPickerProps) { - const [open, setOpen] = useState(false); + const [openPicker, setOpenPicker] = useState(); const [query, setQuery] = useState(""); + const [showNextNotice, setShowNextNotice] = useState(false); const rootRef = useRef(null); + const modelTriggerRef = useRef(null); + const variantTriggerRef = useRef(null); const searchRef = useRef(null); + const modelPopoverRef = useRef(null); + const variantPopoverRef = useRef(null); const principalProfile = catalog.profileDefaults.principal; + const currentModel = findCatalogModel(catalog, next.resolved.selection.model); + const hasVariants = (currentModel?.variants.length ?? 0) > 0; + + const catalogModels = useMemo( + () => catalog.providers.flatMap((provider) => provider.models.map((model) => ({ + model, + providerId: provider.id, + providerDisplayName: provider.displayName, + }))), + [catalog.providers], + ); + const supportsSearch = catalogModels.length > MODEL_SEARCH_THRESHOLD; + const duplicateDisplayNames = useMemo(() => { + const counts = new Map(); + for (const { model } of catalogModels) { + const key = model.displayName.toLocaleLowerCase(); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; + }, [catalogModels]); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const visibleModels = useMemo(() => catalogModels.filter(({ model, providerId, providerDisplayName }) => ( + normalizedQuery.length === 0 + || model.id.toLocaleLowerCase().includes(normalizedQuery) + || model.displayName.toLocaleLowerCase().includes(normalizedQuery) + || model.qualifiedId.toLocaleLowerCase().includes(normalizedQuery) + || providerId.toLocaleLowerCase().includes(normalizedQuery) + || providerDisplayName.toLocaleLowerCase().includes(normalizedQuery) + )), [catalogModels, normalizedQuery]); + const activeDiffers = active !== undefined && ( + active.selection.model !== next.resolved.selection.model + || active.selection.variant !== next.resolved.selection.variant + ); + const nextSelectionKey = [ + next.requested.mode, + next.requested.selection.model, + next.requested.selection.variant ?? "", + ].join("\0"); + const previousNextSelectionKeyRef = useRef(nextSelectionKey); useEffect(() => { - if (!open) return; + if (previousNextSelectionKeyRef.current === nextSelectionKey) return; + previousNextSelectionKeyRef.current = nextSelectionKey; + setShowNextNotice(activeDiffers); + }, [activeDiffers, nextSelectionKey]); + + useEffect(() => { + if (!showNextNotice) return; + const timeout = window.setTimeout(() => setShowNextNotice(false), 2_400); + return () => window.clearTimeout(timeout); + }, [showNextNotice]); + + useEffect(() => { + if (!openPicker) return; const onPointerDown = (event: PointerEvent) => { - if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + if (!rootRef.current?.contains(event.target as Node)) { + setOpenPicker(undefined); + setQuery(""); + } }; const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); + if (event.key !== "Escape") return; + const trigger = openPicker === "model" + ? modelTriggerRef.current + : variantTriggerRef.current; + setOpenPicker(undefined); + setQuery(""); + queueMicrotask(() => trigger?.focus()); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); - queueMicrotask(() => searchRef.current?.focus()); + + queueMicrotask(() => { + if (openPicker === "model" && supportsSearch) { + searchRef.current?.focus(); + return; + } + const popover = openPicker === "model" + ? modelPopoverRef.current + : variantPopoverRef.current; + const selected = popover?.querySelector('[aria-pressed="true"]'); + (selected ?? popover?.querySelector("[data-picker-option]"))?.focus(); + }); + return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; - }, [open]); + }, [openPicker, supportsSearch]); useEffect(() => { - if (disabled || catalog.revision !== next.resolved.modelRuntimeRevision) setOpen(false); + if (disabled || catalog.revision !== next.resolved.modelRuntimeRevision) { + setOpenPicker(undefined); + setQuery(""); + setShowNextNotice(false); + } }, [catalog.revision, disabled, next.resolved.modelRuntimeRevision]); - const normalizedQuery = query.trim().toLocaleLowerCase(); - const providers = useMemo(() => catalog.providers.flatMap((provider) => { - const providerMatches = normalizedQuery.length === 0 - || provider.id.toLocaleLowerCase().includes(normalizedQuery) - || provider.displayName.toLocaleLowerCase().includes(normalizedQuery); - const models = provider.models.flatMap((model) => { - const modelMatches = providerMatches - || model.id.toLocaleLowerCase().includes(normalizedQuery) - || model.displayName.toLocaleLowerCase().includes(normalizedQuery) - || model.qualifiedId.toLocaleLowerCase().includes(normalizedQuery); - const variants = [undefined, ...model.variants].filter((variant) => modelMatches - || (variant?.toLocaleLowerCase().includes(normalizedQuery) ?? false)); - return variants.length === 0 ? [] : [{ model, variants }]; - }); - return models.length === 0 ? [] : [{ provider, models }]; - }), [catalog.providers, normalizedQuery]); - + const closePicker = () => { + setOpenPicker(undefined); + setQuery(""); + }; const select = (selection: RequestedModelSelection) => { onSelect(selection); - setOpen(false); - setQuery(""); + closePicker(); }; - const manageModels = () => { - setOpen(false); - setQuery(""); - onManageModels(); + const selectModel = (model: ModelRuntimeModelDescriptor) => { + if (next.resolved.selection.model === model.qualifiedId) { + closePicker(); + return; + } + select({ + mode: "session_override", + selection: { model: model.qualifiedId }, + }); }; - const runningDifferentModel = active !== undefined && !sameSelection(active.selection, next.resolved.selection); - const nextLabel = bindingLabel(next.resolved); + const selectVariant = (variant?: string) => { + if (next.resolved.selection.variant === variant) { + closePicker(); + return; + } + select({ + mode: "session_override", + selection: { + model: next.resolved.selection.model, + ...(variant === undefined ? {} : { variant }), + }, + }); + }; + + const activeModelDiffers = active !== undefined + && active.selection.model !== next.resolved.selection.model; + const activeModelLabel = active + ? active.modelDisplayName || active.modelId + : undefined; + const nextModelLabel = next.resolved.modelDisplayName || next.resolved.modelId; + const nextVariantLabel = next.resolved.selection.variant ?? "Default"; + const effectiveVariant = next.resolved.selection.variant; if (catalog.revision !== next.resolved.modelRuntimeRevision) { return Refreshing model configuration…; } return ( -
+
+ {showNextNotice && !openPicker && ( + + Applies to the next execution + + )} + - {open && ( + {hasVariants && ( + + )} + + {openPicker === "model" && (