From 66c326b8c424ca1e3702232a4fe5a06f6ba2a525 Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Sun, 19 Apr 2026 20:43:40 -0400 Subject: [PATCH] Redesign model picker with favorites and search (#2153) Co-authored-by: Julius Marminge --- apps/desktop/src/clientPersistence.test.ts | 1 + apps/server/src/keybindings.test.ts | 3 + apps/server/src/keybindings.ts | 7 + .../src/provider/opencodeRuntime.test.ts | 59 +- apps/server/src/provider/opencodeRuntime.ts | 3 +- apps/web/src/components/AppSidebarLayout.tsx | 32 +- apps/web/src/components/ChatView.browser.tsx | 237 +++++ apps/web/src/components/ChatView.tsx | 10 + .../src/components/CommandPalette.logic.ts | 34 +- apps/web/src/components/Icons.tsx | 43 +- apps/web/src/components/Sidebar.tsx | 145 +-- apps/web/src/components/chat/ChatComposer.tsx | 110 +-- .../components/chat/ComposerCommandMenu.tsx | 14 - apps/web/src/components/chat/ModelListRow.tsx | 104 +++ .../components/chat/ModelPickerContent.tsx | 519 +++++++++++ .../components/chat/ModelPickerSidebar.tsx | 210 +++++ .../chat/ProviderModelPicker.browser.tsx | 829 +++++++++++++++--- .../components/chat/ProviderModelPicker.tsx | 277 ++---- .../chat/modelPickerModelHighlights.ts | 13 + .../components/chat/modelPickerSearch.test.ts | 109 +++ .../src/components/chat/modelPickerSearch.ts | 83 ++ .../src/components/chat/providerIconUtils.ts | 52 ++ apps/web/src/components/ui/autocomplete.tsx | 2 +- apps/web/src/components/ui/combobox.tsx | 16 +- apps/web/src/components/ui/scroll-area.tsx | 7 +- apps/web/src/composer-logic.test.ts | 21 +- apps/web/src/composer-logic.ts | 20 +- apps/web/src/hooks/useSettings.ts | 7 +- apps/web/src/index.css | 22 + apps/web/src/keybindings.test.ts | 60 ++ apps/web/src/keybindings.ts | 60 +- apps/web/src/localApi.test.ts | 2 + apps/web/src/modelPickerOpenState.ts | 17 + apps/web/src/modelSelection.ts | 9 +- apps/web/src/session-logic.ts | 6 +- apps/web/src/shortcutModifierState.test.ts | 146 +++ apps/web/src/shortcutModifierState.ts | 96 ++ packages/contracts/src/keybindings.test.ts | 12 + packages/contracts/src/keybindings.ts | 21 + packages/contracts/src/server.ts | 2 + packages/contracts/src/settings.ts | 31 +- 41 files changed, 2874 insertions(+), 577 deletions(-) create mode 100644 apps/web/src/components/chat/ModelListRow.tsx create mode 100644 apps/web/src/components/chat/ModelPickerContent.tsx create mode 100644 apps/web/src/components/chat/ModelPickerSidebar.tsx create mode 100644 apps/web/src/components/chat/modelPickerModelHighlights.ts create mode 100644 apps/web/src/components/chat/modelPickerSearch.test.ts create mode 100644 apps/web/src/components/chat/modelPickerSearch.ts create mode 100644 apps/web/src/components/chat/providerIconUtils.ts create mode 100644 apps/web/src/modelPickerOpenState.ts create mode 100644 apps/web/src/shortcutModifierState.test.ts create mode 100644 apps/web/src/shortcutModifierState.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 27f1e1d91aef5900122ffa35597953242f9d24ca..19f9b09f9fddc54a324ec6d0129cd3fa8a7cc659 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + favorites: [], sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 15edd4295df5f5378e858c526692784dd3f50478..bbb7e1b43006c41a58425585ea56e51efaf7e7cb 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -192,6 +192,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 07ae9156c1c75eb5c1298c5516a65edffbf6132d..9689254c173cb1d44659022a210f3a0d1f7a0749 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -13,6 +13,7 @@ import { KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, + MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, @@ -65,6 +66,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, @@ -72,6 +74,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ key: `mod+${index + 1}`, command, })), + ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + when: "modelPickerOpen", + })), ]; function normalizeKeyToken(token: string): string { diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts index 0ea63f8d534876457eff9c7d28c0d30364a076f7..0e7024efc946dd96c2fd05dfa328868b64b28696 100644 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -1,37 +1,36 @@ -import assert from "node:assert/strict"; +import { describe, expect, it } from "vitest"; -import { describe, it, vi } from "vitest"; +import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts"; -const childProcessMock = vi.hoisted(() => ({ - execFileSync: vi.fn((command: string, args: ReadonlyArray) => { - if (command === "which" && args[0] === "opencode") { - return "/opt/homebrew/bin/opencode\n"; - } - return ""; - }), - spawn: vi.fn(), -})); +describe("flattenOpenCodeModels", () => { + it("keeps the canonical model name separate from the subprovider label", () => { + const models = flattenOpenCodeModels({ + providerList: { + connected: ["github-copilot"], + all: [ + { + id: "github-copilot", + name: "GitHub Copilot", + models: { + "claude-opus-4.5": { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", + variants: {}, + }, + }, + }, + ], + }, + agents: [], + } as unknown as Parameters[0]); -vi.mock("node:child_process", () => childProcessMock); - -describe("resolveOpenCodeBinaryPath", () => { - it("returns absolute binary paths without PATH lookup", async () => { - const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); - - assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); - assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); - }); - - it("resolves command names through PATH", async () => { - const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); - - assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); - assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ - "which", - ["opencode"], + expect(models).toEqual([ { - encoding: "utf8", - timeout: 3_000, + slug: "github-copilot/claude-opus-4.5", + name: "Claude Opus 4.5", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: DEFAULT_OPENCODE_MODEL_CAPABILITIES, }, ]); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 4778f6eac91b80a6cdc8b4734d1d99767acf9b75..91a855bce669cbfaf6066daf71d1a93b983b8053 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -558,7 +558,8 @@ export function flattenOpenCodeModels( for (const model of Object.values(provider.models)) { models.push({ slug: toOpenCodeModelSlug(provider.id, model.id), - name: `${provider.name} · ${model.name}`, + name: model.name, + subProvider: provider.name, isCustom: false, capabilities: openCodeCapabilitiesForModel({ providerID: provider.id, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a2b27bb1e96489fa2e0f5a278edc277e2b638b7f..b1ce57235a825084b69fd427be45e88da82af117 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -3,14 +3,39 @@ import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { + clearShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "../shortcutModifierState"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; - export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowKeyUp = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowBlur = () => { + clearShortcutModifierState(); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + window.addEventListener("keyup", onWindowKeyUp, true); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + window.removeEventListener("keyup", onWindowKeyUp, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -18,8 +43,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { } const unsubscribe = onMenuAction((action) => { - if (action !== "open-settings") return; - void navigate({ to: "/settings" }); + if (action === "open-settings") { + void navigate({ to: "/settings" }); + } }); return () => { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 41f627332e3a551238818170c70ff3da9a10c19f..6857a51b5196f6f12f4ef90938ebed6a78a6bbc5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1384,6 +1384,18 @@ function dispatchChatNewShortcut(): void { ); } +function releaseModShortcut(key?: string): void { + window.dispatchEvent( + new KeyboardEvent("keyup", { + key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), + metaKey: false, + ctrlKey: false, + bubbles: true, + cancelable: true, + }), + ); +} + async function triggerChatNewShortcutUntilPath( router: ReturnType, predicate: (pathname: string) => boolean, @@ -4006,6 +4018,29 @@ describe("ChatView timeline estimator parity (full app)", () => { node: { type: "identifier", name: "terminalFocus" }, }, }, + { + command: "thread.jump.1", + shortcut: { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, ], }; }, @@ -5520,6 +5555,208 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the model picker when selecting /model", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-model-command-target" as MessageId, + targetText: "model command thread", + }), + }); + + try { + await waitForComposerEditor(); + await page.getByTestId("composer-editor").fill("/mod"); + + const menuItem = await waitForComposerMenuItem("slash:model"); + await menuItem.click(); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + + await vi.waitFor(() => { + const searchInput = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInput).not.toBeNull(); + expect(document.activeElement).toBe(searchInput); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, + targetText: "model picker shortcut thread", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID + ? Object.assign({}, project, { + defaultModelSelection: { provider: "codex", model: "gpt-5.4" }, + }) + : project, + ), + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + modelSelection: { provider: "codex", model: "gpt-5.4" }, + }) + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "modelPicker.toggle", + shortcut: { + key: "m", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + modKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + { + command: "thread.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, + ], + providers: [ + { + ...nextFixture.serverConfig.providers[0]!, + provider: "codex", + models: [ + { + slug: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + isCustom: false, + capabilities: { + supportsFastMode: true, + supportsThinkingToggle: false, + reasoningEffortLevels: [], + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + supportsFastMode: true, + supportsThinkingToggle: false, + reasoningEffortLevels: [], + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + supportsFastMode: true, + supportsThinkingToggle: false, + reasoningEffortLevels: [], + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + ], + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + + const initialPath = mounted.router.state.location.pathname; + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + }); + + const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; + await vi.waitFor(() => { + expect( + Array.from( + document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), + ).some((element) => element.textContent?.trim() === jumpLabel), + ).toBe(true); + }); + expect(mounted.router.state.location.pathname).toBe(initialPath); + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).toBeNull(); + }); + } finally { + releaseModShortcut("Control"); + await mounted.cleanup(); + } + }); + it("shows a tooltip with the skill description when hovering a skill pill", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 47dad09ea29c07e35c169c34d06a91aa3af5d654..535c0d9fcaed92f327c9b9802328603eba064de5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2233,6 +2233,7 @@ export default function ChatView(props: ChatViewProps) { const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2282,6 +2283,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "modelPicker.toggle") { + event.preventDefault(); + event.stopPropagation(); + composerRef.current?.toggleModelPicker(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -3341,6 +3349,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadActivities={activeThread?.activities} resolvedTheme={resolvedTheme} settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalState.terminalOpen)} gitCwd={gitCwd} promptRef={promptRef} composerImagesRef={composerImagesRef} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 866db58fb47b5c302998aeb931ff2c3e15149c17..450f678dd5cc384a5496b77f8bac1bf8ca06473e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -151,22 +151,26 @@ export function buildThreadActionItems { - await input.runThread(thread); + return Object.assign( + { + kind: "action" as const, + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``], + title: thread.title, + description: descriptionParts.join(` · `), + timestamp: formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ), + icon: input.icon, }, - }; + leadingContent ? { titleLeadingContent: leadingContent } : {}, + trailingContent ? { titleTrailingContent: trailingContent } : {}, + { + run: async () => { + await input.runThread(thread); + }, + }, + ); }); } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 9a7ac5fbb9879876197a7c06033c8b72c45a61d1..98bc509a6f321fe2574cbf9c1a535929d614d1e4 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,4 +1,5 @@ import { type SVGProps, useId } from "react"; +import { cn } from "~/lib/utils"; export type Icon = React.FC>; @@ -14,8 +15,12 @@ export const GitHubIcon: Icon = (props) => ( ); -export const CursorIcon: Icon = (props) => ( - +export const CursorIcon: Icon = ({ className, ...props }) => ( + ); @@ -290,18 +295,25 @@ export const Zed: Icon = (props) => { ); }; -export const OpenAI: Icon = (props) => ( - +export const OpenAI: Icon = ({ className, ...props }) => ( + ); -export const ClaudeAI: Icon = (props) => ( - - +export const ClaudeAI: Icon = ({ className, ...props }) => ( + + ); @@ -565,3 +577,14 @@ export const OpenCodeIcon: Icon = (props) => ( ); + +export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( + + + +); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8272779399a5865757a7ffd4c7cdcb88feb350e2..8335042c1109f1eb1305b9bf87574951d3e4b4be 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -75,11 +75,13 @@ import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; +import { useModelPickerOpen } from "../modelPickerOpenState"; +import { useShortcutModifierState } from "../shortcutModifierState"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -199,24 +201,6 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; -function threadJumpLabelMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left === right) { - return true; - } - if (left.size !== right.size) { - return false; - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false; - } - } - return true; -} - function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, @@ -2715,6 +2699,8 @@ export default function Sidebar() { const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; + const shortcutModifiers = useShortcutModifierState(); + const modelPickerOpen = useModelPickerOpen(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -2822,8 +2808,9 @@ export default function Sidebar() { routeThreadRef, ).terminalOpen : false, + modelPickerOpen, }), - [routeThreadRef], + [modelPickerOpen, routeThreadRef], ); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -3016,12 +3003,37 @@ export default function Sidebar() { () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], ); - const [threadJumpLabelByKey, setThreadJumpLabelByKey] = - useState>(EMPTY_THREAD_JUMP_LABELS); - const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; + const sidebarShortcutContext = useMemo( + () => ({ + terminalFocus: false, + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + modelPickerOpen, + }), + [modelPickerOpen, routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; @@ -3052,52 +3064,12 @@ export default function Sidebar() { }, [prewarmedSidebarThreadRefs]); useEffect(() => { - const clearThreadJumpHints = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - } if (event.defaultPrevented || event.repeat) { return; @@ -3147,43 +3119,10 @@ export default function Sidebar() { navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); return () => { window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); }; }, [ getCurrentSidebarShortcutContext, @@ -3193,9 +3132,7 @@ export default function Sidebar() { platform, routeThreadKey, sidebarThreadByKey, - threadJumpCommandByKey, threadJumpThreadKeys, - updateThreadJumpHintsVisibility, ]); useEffect(() => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4cc2c85cae6edcbcefcbfa4988a2764541589075..3d3b081af997603b7495bdcdbed5b6e54871d87e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -6,6 +6,7 @@ import type { ProviderApprovalDecision, ProviderInteractionMode, ProviderKind, + ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, @@ -59,7 +60,7 @@ import { shouldUseCompactComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; +import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -317,6 +318,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; + openModelPicker: () => void; + toggleModelPicker: () => void; + isModelPickerOpen: () => boolean; readSnapshot: () => { value: string; cursor: number; @@ -410,6 +414,8 @@ export interface ChatComposerProps { // Misc resolvedTheme: "light" | "dark"; settings: UnifiedSettings; + keybindings: ResolvedKeybindingsConfig; + terminalOpen: boolean; gitCwd: string | null; // Refs the parent needs kept in sync @@ -497,6 +503,8 @@ export const ChatComposer = memo( activeThreadActivities, resolvedTheme, settings, + keybindings, + terminalOpen, gitCwd, promptRef, composerImagesRef, @@ -622,23 +630,6 @@ export const ChatComposer = memo( ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); - const searchableModelOptions = useMemo( - () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], - ); // ------------------------------------------------------------------ // Context window @@ -664,6 +655,7 @@ export const ChatComposer = memo( const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); + const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); // ------------------------------------------------------------------ // Refs @@ -782,31 +774,8 @@ export const ChatComposer = memo( (skill.scope ? `${skill.scope} skill` : "Run provider skill"), })); } - return searchableModelOptions - .filter(({ searchSlug, searchName, searchProvider }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return ( - searchSlug.includes(query) || - searchName.includes(query) || - searchProvider.includes(query) - ); - }) - .map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); - }, [ - composerTrigger, - searchableModelOptions, - selectedProvider, - selectedProviderStatus, - workspaceEntries, - ]); + return []; + }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1276,7 +1245,7 @@ export const ChatComposer = memo( rangeStart: number, rangeEnd: number, replacement: string, - options?: { expectedText?: string }, + options?: { expectedText?: string; focusEditorAfterReplace?: boolean }, ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); @@ -1305,9 +1274,11 @@ export const ChatComposer = memo( } setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCursor); - }); + if (options?.focusEditorAfterReplace !== false) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + } return true; }, [ @@ -1377,20 +1348,13 @@ export const ChatComposer = memo( } if (item.type === "slash-command") { if (item.command === "model") { - const replacement = "/model "; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); if (applied) { setComposerHighlightedItemId(null); + setIsComposerModelPickerOpen(true); } return; } @@ -1439,20 +1403,8 @@ export const ChatComposer = memo( } return; } - onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); - } }, - [ - applyPromptReplacement, - handleInteractionModeChange, - onProviderModelSelect, - resolveActiveComposerTrigger, - ], + [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], ); const onComposerMenuItemHighlighted = useCallback( @@ -1633,6 +1585,13 @@ export const ChatComposer = memo( focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + openModelPicker: () => { + setIsComposerModelPickerOpen(true); + }, + toggleModelPicker: () => { + setIsComposerModelPickerOpen((open) => !open); + }, + isModelPickerOpen: () => isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); }, @@ -1710,6 +1669,7 @@ export const ChatComposer = memo( promptRef, composerImagesRef, composerTerminalContextsRef, + isComposerModelPickerOpen, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, @@ -1925,13 +1885,19 @@ export const ChatComposer = memo( model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} providers={providerStatuses} + keybindings={keybindings} modelOptionsByProvider={modelOptionsByProvider} + terminalOpen={terminalOpen} + open={isComposerModelPickerOpen} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: composerProviderState.modelPickerIconClassName, } : {})} + onOpenChange={(open) => { + setIsComposerModelPickerOpen(open); + }} onProviderModelChange={onProviderModelSelect} /> diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index de7cf2b2b80547091bf27f662f8dd647fb3f8a2e..5d13e6593b4556df1571f6bb5714109d8b1a8ea3 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -10,7 +10,6 @@ import { memo, useLayoutEffect, useMemo, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; import { cn } from "~/lib/utils"; -import { Badge } from "../ui/badge"; import { Command, CommandGroup, @@ -45,14 +44,6 @@ export type ComposerCommandItem = label: string; description: string; } - | { - id: string; - type: "model"; - provider: ProviderKind; - model: string; - label: string; - description: string; - } | { id: string; type: "skill"; @@ -255,11 +246,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { ) : null} - {props.item.type === "model" ? ( - - model - - ) : null} {props.item.label} diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6cd097ad1fe63b24bb6e11b06d402a682c1bdbae --- /dev/null +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -0,0 +1,104 @@ +import { type ProviderKind } from "@t3tools/contracts"; +import { memo } from "react"; +import { StarIcon } from "lucide-react"; +import { + getDisplayModelName, + getProviderLabel, + getTriggerDisplayModelLabel, + type ModelEsque, + PROVIDER_ICON_BY_PROVIDER, +} from "./providerIconUtils"; +import { ComboboxItem } from "../ui/combobox"; +import { Kbd } from "../ui/kbd"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; + +export const ModelListRow = memo(function ModelListRow(props: { + index: number; + model: ModelEsque; + provider: ProviderKind; + isFavorite: boolean; + showProvider: boolean; + preferShortName?: boolean; + useTriggerLabel?: boolean; + showNewBadge?: boolean; + jumpLabel?: string | null; + onToggleFavorite: () => void; +}) { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + + return ( + + + { + event.stopPropagation(); + props.onToggleFavorite(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} + > + + + } + /> + + {props.isFavorite ? "Remove from favorites" : "Add to favorites"} + + + +
+
+
+ + {props.useTriggerLabel + ? getTriggerDisplayModelLabel(props.model) + : getDisplayModelName( + props.model, + props.preferShortName ? { preferShortName: true } : undefined, + )} + + {props.showNewBadge ? ( + + New + + ) : null} +
+ {props.jumpLabel ? ( + + {props.jumpLabel} + + ) : null} +
+ {props.showProvider && ( +
+ + + {getProviderLabel(props.provider, props.model)} + +
+ )} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82720425ef5598b8cb6b969a134fba9f6c613932 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -0,0 +1,519 @@ +import { + type ProviderKind, + PROVIDER_DISPLAY_NAMES, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { resolveSelectableModel } from "@t3tools/shared/model"; +import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { SearchIcon } from "lucide-react"; +import { ModelListRow } from "./ModelListRow"; +import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { isModelPickerNewModel } from "./modelPickerModelHighlights"; +import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; +import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { + modelPickerJumpCommandForIndex, + modelPickerJumpIndexFromCommand, + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../../keybindings"; +import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { cn } from "~/lib/utils"; +import { TooltipProvider } from "../ui/tooltip"; + +type ModelPickerItem = { + slug: string; + name: string; + shortName?: string; + subProvider?: string; + provider: ProviderKind; +}; + +const EMPTY_MODEL_JUMP_LABELS = new Map(); + +export const ModelPickerContent = memo(function ModelPickerContent(props: { + provider: ProviderKind; + model: string; + lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; + keybindings?: ResolvedKeybindingsConfig; + modelOptionsByProvider: Record>; + terminalOpen: boolean; + onRequestClose?: () => void; + onProviderModelChange: (provider: ProviderKind, model: string) => void; +}) { + const { keybindings: providedKeybindings, modelOptionsByProvider, onProviderModelChange } = props; + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const listRegionRef = useRef(null); + const highlightedModelKeyRef = useRef(null); + const favorites = useSettings((s) => s.favorites ?? []); + const [selectedProvider, setSelectedProvider] = useState(() => { + if (props.lockedProvider !== null) { + return props.lockedProvider; + } + return favorites.length > 0 ? "favorites" : props.provider; + }); + const keybindings = useMemo( + () => providedKeybindings ?? [], + [providedKeybindings], + ); + const { updateSettings } = useUpdateSettings(); + + const focusSearchInput = useCallback(() => { + searchInputRef.current?.focus({ preventScroll: true }); + }, []); + + const handleSelectProvider = useCallback( + (provider: ProviderKind | "favorites") => { + setSelectedProvider(provider); + window.requestAnimationFrame(() => { + focusSearchInput(); + }); + }, + [focusSearchInput], + ); + + useLayoutEffect(() => { + focusSearchInput(); + const frame = window.requestAnimationFrame(() => { + focusSearchInput(); + }); + const timeout = window.setTimeout(() => { + focusSearchInput(); + }, 0); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timeout); + }; + }, [focusSearchInput]); + + // Create a Set for efficient lookup + const favoritesSet = useMemo(() => { + return new Set(favorites.map((fav) => `${fav.provider}:${fav.model}`)); + }, [favorites]); + const favoriteOrder = useMemo(() => { + return new Map( + favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), + ); + }, [favorites]); + + const readyProviderSet = useMemo(() => { + if (!props.providers || props.providers.length === 0) { + return null; + } + return new Set( + props.providers + .filter((provider) => provider.status === "ready") + .map((provider) => provider.provider), + ); + }, [props.providers]); + + // Flatten models into a searchable array + const flatModels = useMemo(() => { + return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { + if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { + return []; + } + return models.map((m) => ({ + slug: m.slug, + name: m.name, + ...(m.shortName ? { shortName: m.shortName } : {}), + ...(m.subProvider ? { subProvider: m.subProvider } : {}), + provider: providerKind as ProviderKind, + })) satisfies Array; + }); + }, [props.modelOptionsByProvider, readyProviderSet]); + + // Filter models based on search query and selected provider + const filteredModels = useMemo(() => { + let result = flatModels; + + // Apply tokenized fuzzy search across the combined provider/model search fields. + if (searchQuery.trim()) { + const rankedMatches = result + .map((model) => ({ + model, + score: scoreModelPickerSearch( + { + ...model, + isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + }, + searchQuery, + ), + isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + tieBreaker: buildModelPickerSearchText(model), + })) + .filter( + ( + rankedModel, + ): rankedModel is { + model: ModelPickerItem; + score: number; + isFavorite: boolean; + tieBreaker: string; + } => rankedModel.score !== null, + ); + + // When searching, we only respect locked provider, ignoring sidebar selection + if (props.lockedProvider !== null) { + return rankedMatches + .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider) + .toSorted((a, b) => { + const scoreDelta = a.score - b.score; + if (scoreDelta !== 0) { + return scoreDelta; + } + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } + return a.tieBreaker.localeCompare(b.tieBreaker); + }) + .map((rankedModel) => rankedModel.model); + } + + return rankedMatches + .toSorted((a, b) => { + const scoreDelta = a.score - b.score; + if (scoreDelta !== 0) { + return scoreDelta; + } + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } + return a.tieBreaker.localeCompare(b.tieBreaker); + }) + .map((rankedModel) => rankedModel.model); + } + + // Locked provider mode always shows that provider's models, with favorites first. + if (props.lockedProvider !== null) { + result = result.filter((m) => m.provider === props.lockedProvider); + } else if (selectedProvider === "favorites") { + result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); + } else { + result = result.filter((m) => m.provider === selectedProvider); + } + + return result.toSorted((a, b) => { + const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`); + const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`); + + if (aOrder !== undefined && bOrder !== undefined) { + return aOrder - bOrder; + } + if (aOrder !== undefined) { + return -1; + } + if (bOrder !== undefined) { + return 1; + } + return 0; + }); + }, [ + favoriteOrder, + favoritesSet, + flatModels, + props.lockedProvider, + searchQuery, + selectedProvider, + ]); + + const handleModelSelect = useCallback( + (modelSlug: string, provider: ProviderKind) => { + const resolvedModel = resolveSelectableModel( + provider, + modelSlug, + modelOptionsByProvider[provider], + ); + if (resolvedModel) { + onProviderModelChange(provider, resolvedModel); + } + }, + [modelOptionsByProvider, onProviderModelChange], + ); + + const toggleFavorite = useCallback( + (provider: ProviderKind, model: string) => { + const newFavorites = [...favorites]; + const index = newFavorites.findIndex((f) => f.provider === provider && f.model === model); + if (index >= 0) { + newFavorites.splice(index, 1); + } else { + newFavorites.push({ provider, model }); + } + updateSettings({ favorites: newFavorites }); + }, + [favorites, updateSettings], + ); + + const isLocked = props.lockedProvider !== null; + const isSearching = searchQuery.trim().length > 0; + const showSidebar = !isLocked && !isSearching; + const LockedProviderIcon = + isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; + const modelJumpCommandByKey = useMemo(() => { + const mapping = new Map< + string, + NonNullable> + >(); + for (const [visibleModelIndex, model] of filteredModels.entries()) { + const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(`${model.provider}:${model.slug}`, jumpCommand); + } + return mapping; + }, [filteredModels]); + const modelJumpModelKeys = useMemo( + () => [...modelJumpCommandByKey.keys()], + [modelJumpCommandByKey], + ); + const allModelKeys = useMemo( + (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`), + [flatModels], + ); + const filteredModelKeys = useMemo( + (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`), + [filteredModels], + ); + const filteredModelByKey = useMemo( + (): ReadonlyMap => + new Map(filteredModels.map((model) => [`${model.provider}:${model.slug}`, model] as const)), + [filteredModels], + ); + const modelJumpShortcutContext = useMemo( + () => + ({ + terminalFocus: false, + terminalOpen: props.terminalOpen, + modelPickerOpen: true, + }) as const, + [props.terminalOpen], + ); + const modelJumpLabelByKey = useMemo((): ReadonlyMap => { + if (modelJumpCommandByKey.size === 0) { + return EMPTY_MODEL_JUMP_LABELS; + } + const shortcutLabelOptions = { + platform: navigator.platform, + context: modelJumpShortcutContext, + }; + const mapping = new Map(); + for (const [modelKey, command] of modelJumpCommandByKey) { + const label = shortcutLabelForCommand(keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(modelKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; + }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: modelJumpShortcutContext, + }); + const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetModelKey = modelJumpModelKeys[jumpIndex]; + if (!targetModelKey) { + return; + } + const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string]; + event.preventDefault(); + event.stopPropagation(); + handleModelSelect(slug, provider); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + }; + }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + + useLayoutEffect(() => { + const listRegion = listRegionRef.current; + if (!listRegion) { + return; + } + + let cancelled = false; + let frame = 0; + let nestedFrame = 0; + let timeout = 0; + + const measureScrollArea = () => { + if (cancelled) { + return; + } + const viewport = listRegion.querySelector('[data-slot="scroll-area-viewport"]'); + if (!viewport || viewport.scrollHeight <= viewport.clientHeight) { + return; + } + const originalScrollTop = viewport.scrollTop; + const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; + if (maxScrollTop <= 0) { + return; + } + viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop); + viewport.scrollTop = originalScrollTop; + }; + + queueMicrotask(measureScrollArea); + frame = window.requestAnimationFrame(() => { + measureScrollArea(); + nestedFrame = window.requestAnimationFrame(measureScrollArea); + }); + timeout = window.setTimeout(measureScrollArea, 0); + + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + window.cancelAnimationFrame(nestedFrame); + window.clearTimeout(timeout); + }; + }, [filteredModelKeys]); + + return ( + +
+ {/* Locked provider header (only shown in locked mode) */} + {isLocked && LockedProviderIcon && props.lockedProvider && ( +
+ + + {PROVIDER_DISPLAY_NAMES[props.lockedProvider]} + +
+ )} + + {/* Sidebar (only in unlocked mode) */} + {showSidebar && ( + + )} + + {/* Main content area */} + { + highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; + }} + onValueChange={(modelKey) => { + if (typeof modelKey !== "string") { + return; + } + const [provider, slug] = modelKey.split(":") as [ProviderKind, string]; + handleModelSelect(slug, provider); + }} + > +
+ {/* Search bar */} +
+ } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + props.onRequestClose?.(); + return; + } + if (e.key === "Enter" && highlightedModelKeyRef.current) { + ( + e as typeof e & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + e.preventDefault(); + e.stopPropagation(); + const [provider, slug] = highlightedModelKeyRef.current.split(":") as [ + ProviderKind, + string, + ]; + handleModelSelect(slug, provider); + return; + } + e.stopPropagation(); + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + size="sm" + /> +
+ + {/* Model list */} +
+ + {filteredModelKeys.map((modelKey, index) => { + const model = filteredModelByKey.get(modelKey); + if (!model) { + return null; + } + return ( + toggleFavorite(model.provider, model.slug)} + /> + ); + })} + +
+ + No models found + +
+
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b4f23cacdd55cac0a9cdb41f0922886f970237b2 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -0,0 +1,210 @@ +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { memo } from "react"; +import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; +import { Gemini, GithubCopilotIcon } from "../Icons"; +import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; +import { getProviderSnapshot } from "../../providerModels"; + +function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string { + if (!live) { + return `${label} — waiting for provider status…`; + } + if (live.status === "ready") { + return label; + } + const kind = + live.status === "error" + ? "Unavailable" + : live.status === "warning" + ? "Limited" + : live.status === "disabled" + ? "Disabled in settings" + : "Not ready"; + const msg = live.message?.trim(); + return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; +} + +const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; +const SELECTED_INDICATOR_CLASS = + "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.5 -translate-y-1/2 rounded-l-full bg-primary"; +const BADGE_BASE_CLASS = + "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; +const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; +const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; + +/** Opens toward the rail so the list stays readable (not over the model names). */ +const PICKER_TOOLTIP_SIDE = "left" as const; +const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; + +export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { + selectedProvider: ProviderKind | "favorites"; + onSelectProvider: (provider: ProviderKind | "favorites") => void; + providers?: ReadonlyArray; +}) { + const handleProviderClick = (provider: ProviderKind | "favorites") => { + props.onSelectProvider(provider); + }; + + return ( +
+ {/* Favorites section */} +
+
+ {props.selectedProvider === "favorites" &&
} + + handleProviderClick("favorites")} + type="button" + data-model-picker-provider="favorites" + aria-label="Favorites" + > + + + } + /> + + Favorites + + +
+
+ + {/* Provider buttons */} + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const liveProvider = props.providers + ? getProviderSnapshot(props.providers, option.value) + : undefined; + + const isDisabled = !liveProvider || liveProvider.status !== "ready"; + const isSelected = props.selectedProvider === option.value; + const badge = option.pickerSidebarBadge; + + const providerTooltip = isDisabled + ? describeUnavailableProvider(option.label, liveProvider) + : badge === "new" + ? `${option.label} — New` + : option.label; + + const button = ( + + ); + + const trigger = isDisabled ? ( + {button} + ) : ( + button + ); + + return ( +
+ {isSelected &&
} + + + + {providerTooltip} + + +
+ ); + })} + + {/* Gemini button (coming soon) */} + + + + + } + /> + + Gemini — Coming soon + + + {/* Github Copilot button (coming soon) */} + + + + + } + /> + + Github Copilot — Coming soon + + +
+ ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index abedcd6eeb367ffc8a6733ac4f825cbbb102c99f..f4854dd9a6234ac8ca8e6e5d7703cc17c28b7a78 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,11 +1,70 @@ import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { page } from "vitest/browser"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { page, userEvent } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { getCustomModelOptionsByProvider } from "../../modelSelection"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { __resetLocalApiForTests } from "../../localApi"; + +// Mock the environments/runtime module to provide a mock primary environment connection +vi.mock("../../environments/runtime", () => { + const primaryConnection = { + kind: "primary" as const, + knownEnvironment: { + id: "environment-local", + label: "Local environment", + source: "manual" as const, + environmentId: EnvironmentId.make("environment-local"), + target: { + httpBaseUrl: "http://localhost:3000", + wsBaseUrl: "ws://localhost:3000", + }, + }, + environmentId: EnvironmentId.make("environment-local"), + client: { + server: { + getConfig: vi.fn(), + updateSettings: vi.fn(), + }, + }, + ensureBootstrapped: async () => undefined, + reconnect: async () => undefined, + dispose: async () => undefined, + }; + + return { + getEnvironmentHttpBaseUrl: () => "http://localhost:3000", + getSavedEnvironmentRecord: () => null, + getSavedEnvironmentRuntimeState: () => null, + hasSavedEnvironmentRegistryHydrated: () => true, + listSavedEnvironmentRecords: () => [], + resetSavedEnvironmentRegistryStoreForTests: vi.fn(), + resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), + resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => + new URL(path, "http://localhost:3000").toString(), + waitForSavedEnvironmentRegistryHydration: async () => undefined, + addSavedEnvironment: vi.fn(), + disconnectSavedEnvironment: vi.fn(), + ensureEnvironmentConnectionBootstrapped: async () => undefined, + getPrimaryEnvironmentConnection: () => primaryConnection, + readEnvironmentConnection: () => primaryConnection, + reconnectSavedEnvironment: vi.fn(), + removeSavedEnvironment: vi.fn(), + requireEnvironmentConnection: () => primaryConnection, + resetEnvironmentServiceForTests: vi.fn(), + startEnvironmentConnectionService: vi.fn(), + subscribeEnvironmentConnections: () => () => {}, + useSavedEnvironmentRegistryStore: ( + selector: (state: { byId: Record }) => unknown, + ) => selector({ byId: {} }), + useSavedEnvironmentRuntimeStore: ( + selector: (state: { byId: Record }) => unknown, + ) => selector({ byId: {} }), + }; +}); function effort(value: string, isDefault = false) { return { @@ -129,6 +188,21 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { }; } +function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { + return { + provider: "opencode", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: new Date().toISOString(), + models, + slashCommands: [], + skills: [], + }; +} + async function mountPicker(props: { provider: ProviderKind; model: string; @@ -168,12 +242,40 @@ async function mountPicker(props: { }; } +function getModelPickerListElement() { + const modelPickerList = document.querySelector(".model-picker-list"); + expect(modelPickerList).not.toBeNull(); + return modelPickerList!; +} + +function getModelPickerListText() { + return getModelPickerListElement().textContent ?? ""; +} + +function getVisibleModelNames() { + return Array.from(getModelPickerListElement().querySelectorAll("div.font-medium")) + .map((element) => element.textContent?.replace(/New$/u, "").trim() ?? "") + .filter((text) => text.length > 0); +} + +function getSidebarProviderOrder() { + return Array.from(document.querySelectorAll("[data-model-picker-provider]")).map( + (element) => element.dataset.modelPickerProvider ?? "", + ); +} + describe("ProviderModelPicker", () => { - afterEach(() => { + beforeEach(async () => { + // Reset test environment before each test + await __resetLocalApiForTests(); + }); + + afterEach(async () => { document.body.innerHTML = ""; + await __resetLocalApiForTests(); }); - it("shows provider submenus when provider switching is allowed", async () => { + it("shows provider sidebar in unlocked mode", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -185,82 +287,622 @@ describe("ProviderModelPicker", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Codex"); - expect(text).toContain("Claude"); - expect(text).not.toContain("Claude Sonnet 4.6"); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("opens provider submenus with a visible gap from the parent menu", async () => { - const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: null, - }); - - try { - await page.getByRole("button").click(); - const providerTrigger = page.getByRole("menuitem", { name: "Codex" }); - await providerTrigger.hover(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("GPT-5 Codex"); - }); - - const providerTriggerElement = Array.from( - document.querySelectorAll('[role="menuitem"]'), - ).find((element) => element.textContent?.includes("Codex")); - if (!providerTriggerElement) { - throw new Error("Expected the Codex provider trigger to be mounted."); - } - - const providerTriggerRect = providerTriggerElement.getBoundingClientRect(); - const modelElement = Array.from( - document.querySelectorAll('[role="menuitemradio"]'), - ).find((element) => element.textContent?.includes("GPT-5 Codex")); - if (!modelElement) { - throw new Error("Expected the submenu model option to be mounted."); - } - - const submenuPopup = modelElement.closest('[data-slot="menu-sub-content"]'); - if (!(submenuPopup instanceof HTMLElement)) { - throw new Error("Expected submenu popup to be mounted."); - } - - const submenuRect = submenuPopup.getBoundingClientRect(); - - expect(submenuRect.left).toBeGreaterThanOrEqual(providerTriggerRect.right); - expect(submenuRect.left - providerTriggerRect.right).toBeGreaterThanOrEqual(2); - } finally { - await mounted.cleanup(); - } - }); - - it("shows models directly when the provider is locked mid-thread", async () => { - const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: "claudeAgent", - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Sonnet 4.6"); - expect(text).toContain("Claude Haiku 4.5"); expect(text).not.toContain("Codex"); + expect(text).toContain("Claude"); + expect(text).toContain("Claude Opus 4.6"); }); } finally { await mounted.cleanup(); } }); - it("only shows codex spark when the server reports it for the account", async () => { + it("shows favorites first in the provider sidebar", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ + "favorites", + "codex", + "claudeAgent", + ]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("filters models by selected provider in sidebar", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + // Start with Claude models visible + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).not.toContain("GPT-5 Codex"); + expect(text).toContain("Claude Opus 4.6"); + }); + + // Click on Codex provider in sidebar + await vi.waitFor(() => { + expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); + }); + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + // Now should only show Codex models + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("GPT-5 Codex"); + expect(listText).not.toContain("Claude Opus 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("focuses the search input after selecting a sidebar provider", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); + }); + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + const searchInput = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInput).not.toBeNull(); + expect(document.activeElement).toBe(searchInput); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows locked provider header and only its models in locked mode", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + favorites: [ + { provider: "codex", model: "gpt-5-codex" }, + { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + ], + }), + ); + + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + // Should show locked provider label + expect(text).toContain("Claude"); + expect(getVisibleModelNames()).toEqual([ + "Claude Sonnet 4.6", + "Claude Opus 4.6", + "Claude Haiku 4.5", + ]); + }); + } finally { + localStorage.removeItem("t3code:client-settings:v1"); + await mounted.cleanup(); + } + }); + + it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { + const host = document.createElement("div"); + document.body.append(host); + const onProviderModelChange = vi.fn(); + const modelOptionsByProvider = { + claudeAgent: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }], + cursor: [], + opencode: [], + } as const; + const screen = await render( + , + { container: host }, + ); + + try { + const trigger = document.querySelector( + '[data-chat-provider-model-picker="true"]', + ); + expect(trigger).not.toBeNull(); + const label = trigger?.textContent ?? ""; + expect(label).not.toContain("gpt-5-codex"); + expect(label).toContain("Claude Opus 4.6"); + } finally { + await screen.unmount(); + host.remove(); + } + }); + + it("uses the trigger label for locked opencode rows", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.5", + name: "Claude Opus 4.5", + subProvider: "GitHub Copilot", + shortName: "Opus 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + ]; + const mounted = await mountPicker({ + provider: "opencode", + model: "github-copilot/claude-opus-4.5", + lockedProvider: "opencode", + providers, + }); + + try { + await vi.waitFor(() => { + const trigger = document.querySelector( + '[data-chat-provider-model-picker="true"]', + ); + expect(trigger?.textContent).toContain("GitHub Copilot"); + expect(trigger?.textContent).toContain("Opus 4.5"); + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["GitHub Copilot · Opus 4.5"]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("searches models by name in flat list", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + expect(text).not.toContain("GPT-5 Codex"); + }); + + // Find and type in search box + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("claude"); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + expect(text).not.toContain("GPT-5 Codex"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("supports arrow-key navigation in the model picker", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + + const searchInput = page.getByPlaceholder("Search models..."); + await userEvent.click(searchInput); + await userEvent.keyboard("{ArrowDown}"); + await vi.waitFor(() => { + const highlightedItem = document.querySelector( + '[data-slot="combobox-item"][data-highlighted]', + ); + expect(highlightedItem).not.toBeNull(); + expect(highlightedItem?.textContent).toContain("Claude Opus 4.6"); + }); + await userEvent.keyboard("{ArrowDown}"); + await vi.waitFor(() => { + const highlightedItem = document.querySelector( + '[data-slot="combobox-item"][data-highlighted]', + ); + expect(highlightedItem).not.toBeNull(); + expect(highlightedItem?.textContent).toContain("Claude Sonnet 4.6"); + }); + await userEvent.keyboard("{Enter}"); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith( + "claudeAgent", + "claude-sonnet-4-6", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("hides the provider sidebar while searching", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder().length).toBeGreaterThan(0); + }); + + await page.getByPlaceholder("Search models...").fill("cla"); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder()).toEqual([]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("closes the picker when escape is pressed in search", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.click(); + const searchInputElement = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInputElement).not.toBeNull(); + searchInputElement!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("searches models by provider name", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + expect(text).not.toContain("GPT-5 Codex"); + }); + + // Search by provider name + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("codex"); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("GPT-5 Codex"); + expect(listText).not.toContain("Claude Opus 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("matches fuzzy multi-token queries across provider and model text", async () => { + const providers: ReadonlyArray = [ + buildCodexProvider([ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + ]; + const mounted = await mountPicker({ + provider: "opencode", + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + await page.getByPlaceholder("Search models...").fill("coplt op"); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("Claude Opus 4.7"); + expect(listText).not.toContain("GPT-5 Codex"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("renders each search result with its own provider branding", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + { + ...TEST_PROVIDERS[1]!, + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }, + ]; + const mounted = await mountPicker({ + provider: "opencode", + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + await page.getByPlaceholder("Search models...").fill("opus"); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("OpenCode · GitHub Copilot"); + expect(listText).toContain("Claude"); + expect(listText).not.toContain("OpenCodeClaude Opus 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("toggles favorite stars when clicked", async () => { + localStorage.removeItem("t3code:client-settings:v1"); + + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + }); + + const getFirstStarButton = () => { + const starButton = document.querySelector( + 'button[aria-label*="favorites"]', + ); + expect(starButton).not.toBeNull(); + return starButton!; + }; + + const firstStar = getFirstStarButton(); + const initialAriaLabel = firstStar.getAttribute("aria-label"); + expect( + initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", + ).toBe(true); + + await page.getByRole("button", { name: initialAriaLabel! }).first().click(); + + const expectedAriaLabel = + initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; + + await vi.waitFor(() => { + expect(getFirstStarButton().getAttribute("aria-label")).toBe(expectedAriaLabel); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("does not duplicate favorited models across favorites and all models sections", async () => { + localStorage.removeItem("t3code:client-settings:v1"); + + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + }); + + const favoriteButton = page.getByRole("button", { + name: "Add to favorites", + }); + await favoriteButton.first().click(); + + await vi.waitFor(async () => { + const favoritedModelRows = Array.from( + getModelPickerListElement().querySelectorAll("div.font-medium"), + ).filter((element) => element.textContent?.trim() === "Claude Opus 4.6"); + expect(favoritedModelRows.length).toBe(1); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("shows favorited models first within the selected provider list", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + favorites: [{ provider: "codex", model: "gpt-5.3-codex" }], + }), + ); + + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5.3 Codex", "GPT-5 Codex"]); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("dispatches callback with correct provider and model when selected", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Sonnet 4.6"); + }); + + // Click on a model + const modelRow = page.getByText("Claude Sonnet 4.6").first(); + await modelRow.click(); + + // Verify callback was called with correct values + expect(mounted.onProviderModelChange).toHaveBeenCalledWith( + "claudeAgent", + "claude-sonnet-4-6", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("only shows codex spark when the server reports it", async () => { const providersWithoutSpark: ReadonlyArray = [ buildCodexProvider([ { @@ -309,15 +951,14 @@ describe("ProviderModelPicker", () => { ]; const hidden = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", + provider: "codex", + model: "gpt-5.3-codex", lockedProvider: null, providers: providersWithoutSpark, }); try { await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); await vi.waitFor(() => { const text = document.body.textContent ?? ""; @@ -329,15 +970,14 @@ describe("ProviderModelPicker", () => { } const visible = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", + provider: "codex", + model: "gpt-5.3-codex", lockedProvider: null, providers: providersWithSpark, }); try { await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); await vi.waitFor(() => { expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); @@ -347,27 +987,7 @@ describe("ProviderModelPicker", () => { } }); - it("dispatches the canonical slug when a model is selected", async () => { - const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: "claudeAgent", - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click(); - - expect(mounted.onProviderModelChange).toHaveBeenCalledWith( - "claudeAgent", - "claude-sonnet-4-6", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows disabled providers as non-selectable entries", async () => { + it("shows disabled providers grayed out in sidebar", async () => { const disabledProviders = TEST_PROVIDERS.slice(); const claudeIndex = disabledProviders.findIndex( (provider) => provider.provider === "claudeAgent", @@ -380,6 +1000,7 @@ describe("ProviderModelPicker", () => { status: "disabled", }; } + const mounted = await mountPicker({ provider: "codex", model: "gpt-5-codex", @@ -392,9 +1013,9 @@ describe("ProviderModelPicker", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Claude"); - expect(text).toContain("Disabled"); - expect(text).not.toContain("Claude Sonnet 4.6"); + expect(text).toContain("GPT-5 Codex"); + // Disabled provider should not have its models shown + expect(text).not.toContain("Claude Opus 4.6"); }); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 8b20237a83da72ec99149d25f9d2d175f8c8b2a0..4f4140f834da20a86df39ce7a7f111d7fc45c998 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,87 +1,78 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { resolveSelectableModel } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { + type ProviderKind, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { memo, useEffect, useState } from "react"; import type { VariantProps } from "class-variance-authority"; -import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; -import { - Menu, - MenuGroup, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuSub, - MenuSubPopup, - MenuSubTrigger, - MenuTrigger, -} from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { getProviderSnapshot } from "../../providerModels"; - -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available; -} - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - claudeAgent: ClaudeAI, - opencode: OpenCodeIcon, - cursor: CursorIcon, -}; - -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const; - -function providerIconClassName( - provider: ProviderKind | ProviderPickerKind, - fallbackClassName: string, -): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; -} +import { ModelPickerContent } from "./ModelPickerContent"; +import { + ModelEsque, + PROVIDER_ICON_BY_PROVIDER, + getTriggerDisplayModelLabel, + getTriggerDisplayModelName, +} from "./providerIconUtils"; +import { setModelPickerOpen } from "../../modelPickerOpenState"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; - modelOptionsByProvider: Record>; + keybindings?: ResolvedKeybindingsConfig; + modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; + terminalOpen?: boolean; + open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + onOpenChange?: (open: boolean) => void; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + // If the current slug belongs to a different provider (for example after a provider + // switch or disable), prefer the active provider's first option so the trigger icon + // and label stay in sync instead of showing a stale foreign slug. + const selectedModel = + selectedProviderOptions.find((option) => option.slug === props.model) ?? + selectedProviderOptions[0]; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; - const handleModelChange = (provider: ProviderKind, value: string) => { + const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; + const triggerSubtitle = selectedModel?.subProvider; + const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + + const setIsMenuOpen = (open: boolean) => { + props.onOpenChange?.(open); + if (props.open === undefined) { + setUncontrolledIsMenuOpen(open); + } + }; + + useEffect(() => { + setModelPickerOpen(isMenuOpen); + return () => { + setModelPickerOpen(false); + }; + }, [isMenuOpen]); + + const handleProviderModelChange = (provider: ProviderKind, model: string) => { if (props.disabled) return; - if (!value) return; - const resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); - if (!resolvedModel) return; - props.onProviderModelChange(provider, resolvedModel); + props.onProviderModelChange(provider, model); setIsMenuOpen(false); }; return ( - { if (props.disabled) { @@ -91,7 +82,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(open); }} > - - - {props.lockedProvider !== null ? ( - - handleModelChange(props.lockedProvider!, value)} - > - {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - ) : ( - <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - const liveProvider = props.providers - ? getProviderSnapshot(props.providers, option.value) - : undefined; - if (liveProvider && liveProvider.status !== "ready") { - const unavailableLabel = !liveProvider.enabled - ? "Disabled" - : !liveProvider.installed - ? "Not installed" - : "Unavailable"; - return ( - - - ); - } - return ( - - - - - - handleModelChange(option.value, value)} - > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - return ( - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } - {COMING_SOON_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = option.icon; - return ( - - - ); - })} - - )} - - + + + setIsMenuOpen(false)} + onProviderModelChange={handleProviderModelChange} + /> + + ); }); diff --git a/apps/web/src/components/chat/modelPickerModelHighlights.ts b/apps/web/src/components/chat/modelPickerModelHighlights.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3661fda2b4316b49aeb6dc22e1d757c847da14d --- /dev/null +++ b/apps/web/src/components/chat/modelPickerModelHighlights.ts @@ -0,0 +1,13 @@ +import type { ProviderKind } from "@t3tools/contracts"; + +/** + * Model slugs that show a gold "NEW" chip in the model picker list. + * Add entries as `provider:slug` when you want to highlight freshly shipped models. + */ +const NEW_MODEL_KEYS = new Set([ + // Example: "claudeAgent:claude-opus-4-7", +]); + +export function isModelPickerNewModel(provider: ProviderKind, slug: string): boolean { + return NEW_MODEL_KEYS.has(`${provider}:${slug}`); +} diff --git a/apps/web/src/components/chat/modelPickerSearch.test.ts b/apps/web/src/components/chat/modelPickerSearch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ce629d8a4a7833bb785c64f5588851a47f95d4b --- /dev/null +++ b/apps/web/src/components/chat/modelPickerSearch.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; + +describe("buildModelPickerSearchText", () => { + it("builds provider-agnostic search text from generic fields", () => { + expect( + buildModelPickerSearchText({ + provider: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }), + ).toBe("claude opus 4.7 github copilot opencode opencode"); + }); +}); + +describe("scoreModelPickerSearch", () => { + it("matches typo-tolerant multi-token queries", () => { + expect( + scoreModelPickerSearch( + { + provider: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }, + "coplt op", + ), + ).not.toBeNull(); + }); + + it("rejects results when any query token does not match", () => { + expect( + scoreModelPickerSearch( + { + provider: "codex", + name: "GPT-5 Codex", + }, + "coplt op", + ), + ).toBeNull(); + }); + + it("ranks exact token matches ahead of fuzzier matches", () => { + const exactScore = scoreModelPickerSearch( + { + provider: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }, + "copilot opus", + ); + const fuzzyScore = scoreModelPickerSearch( + { + provider: "opencode", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + }, + "coplt op", + ); + + expect(exactScore).not.toBeNull(); + expect(fuzzyScore).not.toBeNull(); + expect(exactScore!).toBeLessThan(fuzzyScore!); + }); + + it("gives favorite models a strong enough ranking boost for partial queries", () => { + const favoriteScore = scoreModelPickerSearch( + { + provider: "claudeAgent", + name: "Claude Opus 4.7", + isFavorite: true, + }, + "opu", + ); + const nonFavoriteScore = scoreModelPickerSearch( + { + provider: "cursor", + name: "Opus 4.5", + }, + "opu", + ); + + expect(favoriteScore).not.toBeNull(); + expect(nonFavoriteScore).not.toBeNull(); + expect(favoriteScore!).toBeLessThan(nonFavoriteScore!); + }); + + it("does not let the favorite boost outrank clearly better textual matches", () => { + const favoriteScore = scoreModelPickerSearch( + { + provider: "claudeAgent", + name: "Claude Opus 4.7", + isFavorite: true, + }, + "opus 4.7", + ); + const nonFavoriteExactScore = scoreModelPickerSearch( + { + provider: "cursor", + name: "Opus 4.7", + }, + "opus 4.7", + ); + + expect(favoriteScore).not.toBeNull(); + expect(nonFavoriteExactScore).not.toBeNull(); + expect(nonFavoriteExactScore!).toBeLessThan(favoriteScore!); + }); +}); diff --git a/apps/web/src/components/chat/modelPickerSearch.ts b/apps/web/src/components/chat/modelPickerSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4871fa0f31f8355592d2b4ac2549aa395c4f7e9 --- /dev/null +++ b/apps/web/src/components/chat/modelPickerSearch.ts @@ -0,0 +1,83 @@ +import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; + +type ModelPickerSearchableModel = { + provider: ProviderKind; + name: string; + shortName?: string; + subProvider?: string; + isFavorite?: boolean; +}; + +const MODEL_PICKER_FAVORITE_SCORE_BOOST = 24; + +function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] { + return [ + normalizeSearchQuery(model.name), + ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []), + ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []), + normalizeSearchQuery(model.provider), + normalizeSearchQuery(PROVIDER_DISPLAY_NAMES[model.provider]), + buildModelPickerSearchText(model), + ]; +} + +function scoreModelPickerSearchToken( + field: string, + token: string, + fieldBase: number, +): number | null { + return scoreQueryMatch({ + value: field, + query: token, + exactBase: fieldBase, + prefixBase: fieldBase + 2, + boundaryBase: fieldBase + 4, + includesBase: fieldBase + 6, + ...(token.length >= 3 ? { fuzzyBase: fieldBase + 100 } : {}), + }); +} + +export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string { + return normalizeSearchQuery( + [ + model.name, + model.shortName, + model.subProvider, + model.provider, + PROVIDER_DISPLAY_NAMES[model.provider], + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" "), + ); +} + +export function scoreModelPickerSearch( + model: ModelPickerSearchableModel, + query: string, +): number | null { + const tokens = normalizeSearchQuery(query) + .split(/\s+/u) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + return 0; + } + + const fields = getModelPickerSearchFields(model); + let score = 0; + + for (const token of tokens) { + const tokenScores = fields + .map((field, index) => scoreModelPickerSearchToken(field, token, index * 10)) + .filter((fieldScore): fieldScore is number => fieldScore !== null); + + if (tokenScores.length === 0) { + return null; + } + + score += Math.min(...tokenScores); + } + + return model.isFavorite ? score - MODEL_PICKER_FAVORITE_SCORE_BOOST : score; +} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..66f5220771c270ec2199881ecc7e676b110a2658 --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -0,0 +1,52 @@ +import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { PROVIDER_OPTIONS } from "../../session-logic"; + +export const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + claudeAgent: ClaudeAI, + opencode: OpenCodeIcon, + cursor: CursorIcon, +}; + +function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { + value: ProviderKind; + label: string; + available: true; + pickerSidebarBadge?: "new" | "soon"; +} { + return option.available; +} + +export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); + +export type ModelEsque = { + slug: string; + name: string; + shortName?: string | undefined; + subProvider?: string | undefined; +}; + +export function getProviderLabel(provider: ProviderKind, model: ModelEsque): string { + const providerLabel = PROVIDER_DISPLAY_NAMES[provider]; + return model.subProvider ? `${providerLabel} · ${model.subProvider}` : providerLabel; +} + +export function getDisplayModelName( + model: ModelEsque, + options?: { preferShortName?: boolean }, +): string { + if (options?.preferShortName && model.shortName) { + return model.shortName; + } + return model.name; +} + +export function getTriggerDisplayModelName(model: ModelEsque): string { + return getDisplayModelName(model, { preferShortName: true }); +} + +export function getTriggerDisplayModelLabel(model: ModelEsque): string { + const title = getTriggerDisplayModelName(model); + return model.subProvider ? `${model.subProvider} · ${title}` : title; +} diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 676db613ae1efe1e065b96d5564e7363ccf9cff8..d6db87d48c2a712f2b2bdad3d19464b48c47ec2d 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -193,7 +193,7 @@ function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Pr return ( diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 52dd996ec569def4da036e5c5e6ac4d8126f00e5..ff6056e9cfc8b41652bd083148abbe4d10fe86f1 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -186,10 +186,12 @@ function ComboboxPopup({ function ComboboxItem({ className, + contentClassName, children, hideIndicator = false, ...props }: ComboboxPrimitive.Item.Props & { + contentClassName?: string; hideIndicator?: boolean; }) { return ( @@ -204,7 +206,14 @@ function ComboboxItem({ -
{children}
+
+ {children} +
); } @@ -264,10 +273,7 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { return ( diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 50deb1ba87176f2e8b6f690017a6a92d330e4f82..c8aa6dc96dbbc076c325c1d9f554ea34e4e8404b 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -17,13 +17,16 @@ function ScrollArea({ hideScrollbars?: boolean; }) { return ( - + { }); }); - it("detects slash model query after /model", () => { + it("keeps /model as a slash command item", () => { + const text = "/model"; + const trigger = detectComposerTrigger(text, text.length); + + expect(trigger).toEqual({ + kind: "slash-command", + query: "model", + rangeStart: 0, + rangeEnd: text.length, + }); + }); + + it("does not keep a subcommand trigger active after /model arguments", () => { const text = "/model spark"; const trigger = detectComposerTrigger(text, text.length); - expect(trigger).toEqual({ - kind: "slash-model", - query: "spark", - rangeStart: 0, - rangeEnd: text.length, - }); + expect(trigger).toBeNull(); }); it("detects non-model slash commands while typing", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index a5b26b0e2de2a0043399c485a3654c59fecc8fc5..fb63d2581c7a7797ade268e23126226e1fcaf206 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,7 +1,7 @@ import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; -export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; +export type ComposerTriggerKind = "path" | "slash-command" | "skill"; export type ComposerSlashCommand = "model" | "plan" | "default"; export interface ComposerTrigger { @@ -224,14 +224,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos const commandMatch = /^\/(\S*)$/.exec(linePrefix); if (commandMatch) { const commandQuery = commandMatch[1] ?? ""; - if (commandQuery.toLowerCase() === "model") { - return { - kind: "slash-model", - query: "", - rangeStart: lineStart, - rangeEnd: cursor, - }; - } return { kind: "slash-command", query: commandQuery, @@ -239,16 +231,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos rangeEnd: cursor, }; } - - const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); - if (modelMatch) { - return { - kind: "slash-model", - query: (modelMatch[1] ?? "").trim(), - rangeStart: lineStart, - rangeEnd: cursor, - }; - } } const tokenStart = tokenStartForCursor(text, cursor); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index c2301b23a3fc4b845d2633d42ab3a6d3fd0bc226..18b2668a603fcea6a70a3c7ffa79cd1f33ebccfd 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -10,8 +10,9 @@ * store. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; -import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; import { + type ClientSettingsPatch, type ClientSettings, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, @@ -98,7 +99,7 @@ const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)) function splitPatch(patch: Partial): { serverPatch: ServerSettingsPatch; - clientPatch: Partial; + clientPatch: ClientSettingsPatch; } { const serverPatch: Record = {}; const clientPatch: Record = {}; @@ -111,7 +112,7 @@ function splitPatch(patch: Partial): { } return { serverPatch: serverPatch as ServerSettingsPatch, - clientPatch: clientPatch as Partial, + clientPatch: clientPatch as ClientSettingsPatch, }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6a4db39855ec7cd475b5eed0e332562e828ef353..5c568ad5d67061915dc674c6e9a5b05999ada9d1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -534,3 +534,25 @@ label:has(> select#reasoning-effort) select { -webkit-background-clip: text; animation: ultrathink-rainbow 10s linear infinite; } + +/* Thin scrollbar for model picker */ +.model-picker-list::-webkit-scrollbar { + width: 4px; +} + +.model-picker-list::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +.model-picker-list::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.2); +} + +.dark .model-picker-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); +} + +.dark .model-picker-list::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 683afbbfe44f03a2e6b65fff1f248f0ac67cedac..85c14fa0ab7b2b74777b10fb4b8047bb7fd1b39b 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -11,6 +11,8 @@ import { isChatNewShortcut, isChatNewLocalShortcut, isDiffToggleShortcut, + modelPickerJumpCommandForIndex, + modelPickerJumpIndexFromCommand, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, @@ -18,6 +20,7 @@ import { isTerminalSplitShortcut, isTerminalToggleShortcut, resolveShortcutCommand, + shouldShowModelPickerJumpHints, shouldShowThreadJumpHints, shortcutLabelForCommand, terminalDeleteShortcutData, @@ -107,6 +110,11 @@ const DEFAULT_BINDINGS = compile([ command: "commandPalette.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("m", { shiftKey: true }), + command: "modelPicker.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -115,6 +123,21 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("1"), command: "thread.jump.1" }, { shortcut: modShortcut("2"), command: "thread.jump.2" }, { shortcut: modShortcut("3"), command: "thread.jump.3" }, + { + shortcut: modShortcut("1"), + command: "modelPicker.jump.1", + whenAst: whenIdentifier("modelPickerOpen"), + }, + { + shortcut: modShortcut("2"), + command: "modelPicker.jump.2", + whenAst: whenIdentifier("modelPickerOpen"), + }, + { + shortcut: modShortcut("3"), + command: "modelPicker.jump.3", + whenAst: whenIdentifier("modelPickerOpen"), + }, ]); describe("isTerminalToggleShortcut", () => { @@ -268,6 +291,10 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), + "Ctrl+Shift+M", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -280,6 +307,13 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), "Ctrl+Shift+[", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.jump.3", { + platform: "MacIntel", + context: { modelPickerOpen: true }, + }), + "⌘3", + ); }); it("returns null for commands shadowed by a later conflicting shortcut", () => { @@ -361,6 +395,32 @@ describe("thread navigation helpers", () => { }); }); +describe("model picker navigation helpers", () => { + it("maps jump commands to visible model indices", () => { + assert.strictEqual(modelPickerJumpCommandForIndex(0), "modelPicker.jump.1"); + assert.strictEqual(modelPickerJumpCommandForIndex(2), "modelPicker.jump.3"); + assert.isNull(modelPickerJumpCommandForIndex(9)); + assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.1"), 0); + assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); + assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); + }); + + it("shows jump hints only while the model picker context is active", () => { + assert.isFalse( + shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { modelPickerOpen: false }, + }), + ); + assert.isTrue( + shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { modelPickerOpen: true }, + }), + ); + }); +}); + describe("chat/editor shortcuts", () => { it("matches chat.new shortcut", () => { assert.isTrue( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 977c1319b9e28dec9d9a02051c2fa69302bf7a52..dbf2450f7944bc7277f48206b60a3d64ee84d15a 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -2,8 +2,10 @@ import { type KeybindingCommand, type KeybindingShortcut, type KeybindingWhenNode, + MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, type ResolvedKeybindingsConfig, THREAD_JUMP_KEYBINDING_COMMANDS, + type ModelPickerJumpKeybindingCommand, type ThreadJumpKeybindingCommand, } from "@t3tools/contracts"; import { isMacPlatform } from "./lib/utils"; @@ -18,6 +20,13 @@ export interface ShortcutEventLike { altKey: boolean; } +export interface ShortcutModifierStateLike { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; @@ -71,7 +80,7 @@ function resolveEventKeys(event: ShortcutEventLike): Set { } function matchesShortcutModifiers( - event: ShortcutEventLike, + event: ShortcutModifierStateLike, shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { @@ -269,13 +278,60 @@ export function shouldShowThreadJumpHints( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, +): boolean { + return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); +} + +export function shouldShowThreadJumpHintsForModifiers( + modifiers: ShortcutModifierStateLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, ): boolean { const platform = resolvePlatform(options); for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); if (!shortcut) continue; - if (matchesShortcutModifiers(event, shortcut, platform)) { + if (matchesShortcutModifiers(modifiers, shortcut, platform)) { + return true; + } + } + + return false; +} + +export function modelPickerJumpCommandForIndex( + index: number, +): ModelPickerJumpKeybindingCommand | null { + return MODEL_PICKER_JUMP_KEYBINDING_COMMANDS[index] ?? null; +} + +export function modelPickerJumpIndexFromCommand(command: string): number | null { + const index = MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.indexOf( + command as ModelPickerJumpKeybindingCommand, + ); + return index === -1 ? null : index; +} + +export function shouldShowModelPickerJumpHints( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); +} + +export function shouldShowModelPickerJumpHintsForModifiers( + modifiers: ShortcutModifierStateLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + const platform = resolvePlatform(options); + + for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { + const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); + if (!shortcut) continue; + if (matchesShortcutModifiers(modifiers, shortcut, platform)) { return true; } } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 4258ccb3802806b37d270d9596541ade2c5dcf01..f53122175399af68dd6c2175bfe97cca14c972ae 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -532,6 +532,7 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + favorites: [], sidebarProjectGroupingMode: "repository_path" as const, sidebarProjectGroupingOverrides: { "environment-local:/tmp/project": "separate" as const, @@ -589,6 +590,7 @@ describe("wsApi", () => { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + favorites: [], sidebarProjectGroupingMode: "repository_path" as const, sidebarProjectGroupingOverrides: { "environment-local:/tmp/project": "separate" as const, diff --git a/apps/web/src/modelPickerOpenState.ts b/apps/web/src/modelPickerOpenState.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a4993c16e7c04e4b12885dc23f480969d5ea087 --- /dev/null +++ b/apps/web/src/modelPickerOpenState.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +const useModelPickerOpenStore = create<{ + open: boolean; + setOpen: (open: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open) => set((current) => (current.open === open ? current : { open })), +})); + +export function useModelPickerOpen(): boolean { + return useModelPickerOpenStore((store) => store.open); +} + +export function setModelPickerOpen(open: boolean): void { + useModelPickerOpenStore.getState().setOpen(open); +} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 60ab386b8395028f3854432edb89ca0426c60ea8..2ffa8e20140c34761e5afe477f199d50ff30e1cc 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -16,6 +16,7 @@ import { getProviderModels, resolveSelectableProvider, } from "./providerModels"; +import { ModelEsque } from "./components/chat/providerIconUtils"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; @@ -31,6 +32,8 @@ export type ProviderCustomModelConfig = { export interface AppModelOption { slug: string; name: string; + shortName?: string; + subProvider?: string; isCustom: boolean; } @@ -103,9 +106,11 @@ export function getAppModelOptions( selectedModel?: string | null, ): AppModelOption[] { const options: AppModelOption[] = getProviderModels(providers, provider).map( - ({ slug, name, isCustom }) => ({ + ({ slug, name, shortName, subProvider, isCustom }) => ({ slug, name, + ...(shortName ? { shortName } : {}), + ...(subProvider ? { subProvider } : {}), isCustom, }), ); @@ -169,7 +174,7 @@ export function getCustomModelOptionsByProvider( providers: ReadonlyArray, selectedProvider?: ProviderKind | null, selectedModel?: string | null, -): Record> { +): Record> { return { codex: getAppModelOptions( settings, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 477bc57bea01e97cb717ba77844f7418e7f4c6c5..44e0498d7b9a5c67b4799051e395114f26bd73b3 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -28,11 +28,13 @@ export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; label: string; available: boolean; + /** Shown on the model picker sidebar when relevant */ + pickerSidebarBadge?: "new" | "soon"; }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "opencode", label: "OpenCode", available: true }, - { value: "cursor", label: "Cursor", available: true }, + { value: "opencode", label: "OpenCode", available: true, pickerSidebarBadge: "new" }, + { value: "cursor", label: "Cursor", available: true, pickerSidebarBadge: "new" }, ]; export interface WorkLogEntry { diff --git a/apps/web/src/shortcutModifierState.test.ts b/apps/web/src/shortcutModifierState.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..46a7fc4aac3b9ca78bc0f38d1342610d2dec139c --- /dev/null +++ b/apps/web/src/shortcutModifierState.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { + areShortcutModifierStatesEqual, + clearShortcutModifierState, + readShortcutModifierState, + setShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "./shortcutModifierState"; + +function keyboardEventLike(type: "keydown" | "keyup", init: Partial): KeyboardEvent { + return { + type, + key: "", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...init, + } as KeyboardEvent; +} + +describe("shortcutModifierState", () => { + it("compares modifier states by value", () => { + expect( + areShortcutModifierStatesEqual( + { metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, + { metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, + ), + ).toBe(true); + expect( + areShortcutModifierStatesEqual( + { metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, + { metaKey: false, ctrlKey: false, altKey: false, shiftKey: true }, + ), + ).toBe(false); + }); + + it("preserves the current store object when modifier values do not change", () => { + clearShortcutModifierState(); + + const initialState = readShortcutModifierState(); + setShortcutModifierState({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + + expect(readShortcutModifierState()).toBe(initialState); + + setShortcutModifierState({ + metaKey: false, + ctrlKey: true, + altKey: false, + shiftKey: false, + }); + const updatedState = readShortcutModifierState(); + expect(updatedState).not.toBe(initialState); + expect(updatedState).toEqual({ + metaKey: false, + ctrlKey: true, + altKey: false, + shiftKey: false, + }); + + setShortcutModifierState({ + metaKey: false, + ctrlKey: true, + altKey: false, + shiftKey: false, + }); + expect(readShortcutModifierState()).toBe(updatedState); + + clearShortcutModifierState(); + const clearedState = readShortcutModifierState(); + expect(clearedState).toEqual({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + expect(clearedState).not.toBe(updatedState); + + clearShortcutModifierState(); + expect(readShortcutModifierState()).toBe(clearedState); + }); + + it("tracks bare modifier keydown and keyup events explicitly", () => { + clearShortcutModifierState(); + + syncShortcutModifierStateFromKeyboardEvent( + keyboardEventLike("keydown", { + key: "Meta", + metaKey: false, + }), + ); + expect(readShortcutModifierState()).toEqual({ + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + + syncShortcutModifierStateFromKeyboardEvent( + keyboardEventLike("keydown", { + key: "Shift", + metaKey: true, + shiftKey: false, + }), + ); + expect(readShortcutModifierState()).toEqual({ + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + + syncShortcutModifierStateFromKeyboardEvent( + keyboardEventLike("keyup", { + key: "Meta", + metaKey: true, + shiftKey: true, + }), + ); + expect(readShortcutModifierState()).toEqual({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + + syncShortcutModifierStateFromKeyboardEvent( + keyboardEventLike("keyup", { + key: "Shift", + shiftKey: true, + }), + ); + expect(readShortcutModifierState()).toEqual({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + }); +}); diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts new file mode 100644 index 0000000000000000000000000000000000000000..370f3b0a70c39c8fffa32a86a240d3b5eb643a5b --- /dev/null +++ b/apps/web/src/shortcutModifierState.ts @@ -0,0 +1,96 @@ +import { create } from "zustand"; + +export interface ShortcutModifierState { + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +const EMPTY_SHORTCUT_MODIFIER_STATE: ShortcutModifierState = { + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, +}; + +export function areShortcutModifierStatesEqual( + left: ShortcutModifierState, + right: ShortcutModifierState, +): boolean { + return ( + left.metaKey === right.metaKey && + left.ctrlKey === right.ctrlKey && + left.altKey === right.altKey && + left.shiftKey === right.shiftKey + ); +} + +const useShortcutModifierStateStore = create<{ + state: ShortcutModifierState; + setState: (state: ShortcutModifierState) => void; + clear: () => void; +}>((set) => ({ + state: EMPTY_SHORTCUT_MODIFIER_STATE, + setState: (state) => + set((current) => (areShortcutModifierStatesEqual(current.state, state) ? current : { state })), + clear: () => + set((current) => + areShortcutModifierStatesEqual(current.state, EMPTY_SHORTCUT_MODIFIER_STATE) + ? current + : { state: EMPTY_SHORTCUT_MODIFIER_STATE }, + ), +})); + +export function useShortcutModifierState(): ShortcutModifierState { + return useShortcutModifierStateStore((store) => store.state); +} + +function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { + switch (key) { + case "Meta": + case "OS": + case "Command": + return "metaKey"; + case "Control": + return "ctrlKey"; + case "Alt": + case "Option": + return "altKey"; + case "Shift": + return "shiftKey"; + default: + return null; + } +} + +export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void { + const normalizedModifierKey = normalizeModifierKey(event.key); + if (normalizedModifierKey) { + const currentState = useShortcutModifierStateStore.getState().state; + useShortcutModifierStateStore.getState().setState({ + ...currentState, + [normalizedModifierKey]: event.type === "keydown", + }); + return; + } + + useShortcutModifierStateStore.getState().setState({ + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + }); +} + +export function setShortcutModifierState(state: ShortcutModifierState): void { + useShortcutModifierStateStore.getState().setState(state); +} + +export function clearShortcutModifierState(): void { + useShortcutModifierStateStore.getState().clear(); +} + +export function readShortcutModifierState(): ShortcutModifierState { + return useShortcutModifierStateStore.getState().state; +} diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 79c2feb8baaa16a59185b1a4a3f4515d3898a919..f040697013b8539f1388afb63b4be202885e2277 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -53,6 +53,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { + key: "mod+shift+m", + command: "modelPicker.toggle", + }); + assert.strictEqual(parsedModelPickerToggle.command, "modelPicker.toggle"); + + const parsedModelPickerJump = yield* decode(KeybindingRule, { + key: "mod+1", + command: "modelPicker.jump.1", + }); + assert.strictEqual(parsedModelPickerJump.command, "modelPicker.jump.1"); + const parsedThreadPrevious = yield* decode(KeybindingRule, { key: "mod+shift+[", command: "thread.previous", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 1296e74c1b90cc9ade2f850663ff4778265861a8..d3b85d1cdaae824a9e79cfa0a76e5559a621cf5c 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -20,6 +20,20 @@ export const THREAD_JUMP_KEYBINDING_COMMANDS = [ ] as const; export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; +export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [ + "modelPicker.jump.1", + "modelPicker.jump.2", + "modelPicker.jump.3", + "modelPicker.jump.4", + "modelPicker.jump.5", + "modelPicker.jump.6", + "modelPicker.jump.7", + "modelPicker.jump.8", + "modelPicker.jump.9", +] as const; +export type ModelPickerJumpKeybindingCommand = + (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; + export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", @@ -27,6 +41,12 @@ export const THREAD_KEYBINDING_COMMANDS = [ ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; +export const MODEL_PICKER_KEYBINDING_COMMANDS = [ + "modelPicker.toggle", + ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, +] as const; +export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; + const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", @@ -37,6 +57,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c08dfa6cd1c55dd7a83a3ba7b9c27de1ce343638..3cd25f2e8e9bef07a9785983dbddb41e7403e835 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -53,6 +53,8 @@ export type ServerProviderAuth = typeof ServerProviderAuth.Type; export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, + shortName: Schema.optional(TrimmedNonEmptyString), + subProvider: Schema.optional(TrimmedNonEmptyString), isCustom: Schema.Boolean, capabilities: Schema.NullOr(ModelCapabilities), }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6c45dde53007d6178f7981c4eeaf428572821b9b..cad1d197c12fdf41b0a7a6c106eaa7b2bd1ee4dd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -9,7 +9,7 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, OpenCodeModelOptions, } from "./model.ts"; -import { ModelSelection } from "./orchestration.ts"; +import { ModelSelection, ProviderKind } from "./orchestration.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -37,6 +37,12 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + favorites: Schema.Array( + Schema.Struct({ + provider: ProviderKind, + model: TrimmedNonEmptyString, + }), + ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -241,6 +247,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), addProjectBaseDirectory: Schema.optionalKey(Schema.String), @@ -261,3 +268,25 @@ export const ServerSettingsPatch = Schema.Struct({ ), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; + +export const ClientSettingsPatch = Schema.Struct({ + confirmThreadArchive: Schema.optionalKey(Schema.Boolean), + confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + diffWordWrap: Schema.optionalKey(Schema.Boolean), + favorites: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + provider: ProviderKind, + model: TrimmedNonEmptyString, + }), + ), + ), + sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), + sidebarProjectGroupingOverrides: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), + ), + sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), + sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), + timestampFormat: Schema.optionalKey(TimestampFormat), +}); +export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; -- 2.54.0