| author | |
| committer | |
| log | 66c326b8c424ca1e3702232a4fe5a06f6ba2a525 |
| tree | 6b906a586d1a141007660e7cda106f8aba762b44 |
| parent | 8dbcf92a0d125050988474f258df3e55c538efec |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>41 files changed, 2832 insertions(+), 535 deletions(-)
apps/desktop/src/clientPersistence.test.ts+1| ... | @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { | ... | @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { |
| 52 | confirmThreadArchive: true, | 52 | confirmThreadArchive: true, |
| 53 | confirmThreadDelete: false, | 53 | confirmThreadDelete: false, |
| 54 | diffWordWrap: true, | 54 | diffWordWrap: true, |
| 55 | favorites: [], | ||
| 55 | sidebarProjectGroupingMode: "repository_path", | 56 | sidebarProjectGroupingMode: "repository_path", |
| 56 | sidebarProjectGroupingOverrides: { | 57 | sidebarProjectGroupingOverrides: { |
| 57 | "environment-1:/tmp/project-a": "separate", | 58 | "environment-1:/tmp/project-a": "separate", |
apps/server/src/keybindings.test.ts+3| ... | @@ -192,6 +192,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -192,6 +192,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 192 | assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); | 192 | assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); |
| 193 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); | 193 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
| 194 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); | 194 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); |
| 195 | assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); | ||
| 196 | assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); | ||
| 197 | assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); | ||
| 195 | }), | 198 | }), |
| 196 | ); | 199 | ); |
| 197 | 200 |
apps/server/src/keybindings.ts+7| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | KeybindingShortcut, | 13 | KeybindingShortcut, |
| 14 | KeybindingWhenNode, | 14 | KeybindingWhenNode, |
| 15 | MAX_KEYBINDINGS_COUNT, | 15 | MAX_KEYBINDINGS_COUNT, |
| 16 | MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | ||
| 16 | MAX_WHEN_EXPRESSION_DEPTH, | 17 | MAX_WHEN_EXPRESSION_DEPTH, |
| 17 | ResolvedKeybindingRule, | 18 | ResolvedKeybindingRule, |
| 18 | ResolvedKeybindingsConfig, | 19 | ResolvedKeybindingsConfig, |
| ... | @@ -65,6 +66,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -65,6 +66,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 65 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, | 66 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 66 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, | 67 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 67 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, | 68 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| 69 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, | ||
| 68 | { key: "mod+o", command: "editor.openFavorite" }, | 70 | { key: "mod+o", command: "editor.openFavorite" }, |
| 69 | { key: "mod+shift+[", command: "thread.previous" }, | 71 | { key: "mod+shift+[", command: "thread.previous" }, |
| 70 | { key: "mod+shift+]", command: "thread.next" }, | 72 | { key: "mod+shift+]", command: "thread.next" }, |
| ... | @@ -72,6 +74,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -72,6 +74,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 72 | key: `mod+${index + 1}`, | 74 | key: `mod+${index + 1}`, |
| 73 | command, | 75 | command, |
| 74 | })), | 76 | })), |
| 77 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | ||
| 78 | key: `mod+${index + 1}`, | ||
| 79 | command, | ||
| 80 | when: "modelPickerOpen", | ||
| 81 | })), | ||
| 75 | ]; | 82 | ]; |
| 76 | 83 | ||
| 77 | function normalizeKeyToken(token: string): string { | 84 | function normalizeKeyToken(token: string): string { |
apps/server/src/provider/opencodeRuntime.test.ts+29-30| ... | @@ -1,37 +1,36 @@ | ... | @@ -1,37 +1,36 @@ |
| 1 | import assert from "node:assert/strict"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { describe, it, vi } from "vitest"; | 3 | import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts"; |
| 4 | 4 | ||
| 5 | const childProcessMock = vi.hoisted(() => ({ | 5 | describe("flattenOpenCodeModels", () => { |
| 6 | execFileSync: vi.fn((command: string, args: ReadonlyArray<string>) => { | 6 | it("keeps the canonical model name separate from the subprovider label", () => { |
| 7 | if (command === "which" && args[0] === "opencode") { | 7 | const models = flattenOpenCodeModels({ |
| 8 | return "/opt/homebrew/bin/opencode\n"; | 8 | providerList: { |
| 9 | } | 9 | connected: ["github-copilot"], |
| 10 | return ""; | 10 | all: [ |
| 11 | }), | 11 | { |
| 12 | spawn: vi.fn(), | 12 | id: "github-copilot", |
| 13 | })); | 13 | name: "GitHub Copilot", |
| 14 | 14 | models: { | |
| 15 | vi.mock("node:child_process", () => childProcessMock); | 15 | "claude-opus-4.5": { |
| 16 | 16 | id: "claude-opus-4.5", | |
| 17 | describe("resolveOpenCodeBinaryPath", () => { | 17 | name: "Claude Opus 4.5", |
| 18 | it("returns absolute binary paths without PATH lookup", async () => { | 18 | variants: {}, |
| 19 | const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); | 19 | }, |
| 20 | 20 | }, | |
| 21 | assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); | 21 | }, |
| 22 | assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); | 22 | ], |
| 23 | }); | 23 | }, |
| 24 | 24 | agents: [], | |
| 25 | it("resolves command names through PATH", async () => { | 25 | } as unknown as Parameters<typeof flattenOpenCodeModels>[0]); |
| 26 | const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); | ||
| 27 | 26 | ||
| 28 | assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); | 27 | expect(models).toEqual([ |
| 29 | assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ | ||
| 30 | "which", | ||
| 31 | ["opencode"], | ||
| 32 | { | 28 | { |
| 33 | encoding: "utf8", | 29 | slug: "github-copilot/claude-opus-4.5", |
| 34 | timeout: 3_000, | 30 | name: "Claude Opus 4.5", |
| 31 | subProvider: "GitHub Copilot", | ||
| 32 | isCustom: false, | ||
| 33 | capabilities: DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 35 | }, | 34 | }, |
| 36 | ]); | 35 | ]); |
| 37 | }); | 36 | }); |
apps/server/src/provider/opencodeRuntime.ts+2-1| ... | @@ -558,7 +558,8 @@ export function flattenOpenCodeModels( | ... | @@ -558,7 +558,8 @@ export function flattenOpenCodeModels( |
| 558 | for (const model of Object.values(provider.models)) { | 558 | for (const model of Object.values(provider.models)) { |
| 559 | models.push({ | 559 | models.push({ |
| 560 | slug: toOpenCodeModelSlug(provider.id, model.id), | 560 | slug: toOpenCodeModelSlug(provider.id, model.id), |
| 561 | name: `${provider.name} · ${model.name}`, | 561 | name: model.name, |
| 562 | subProvider: provider.name, | ||
| 562 | isCustom: false, | 563 | isCustom: false, |
| 563 | capabilities: openCodeCapabilitiesForModel({ | 564 | capabilities: openCodeCapabilitiesForModel({ |
| 564 | providerID: provider.id, | 565 | providerID: provider.id, |
apps/web/src/components/AppSidebarLayout.tsx+29-3| ... | @@ -3,14 +3,39 @@ import { useNavigate } from "@tanstack/react-router"; | ... | @@ -3,14 +3,39 @@ import { useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | ||
| 4 | import ThreadSidebar from "./Sidebar"; | 4 | import ThreadSidebar from "./Sidebar"; |
| 5 | import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; | 5 | import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; |
| 6 | import { | ||
| 7 | clearShortcutModifierState, | ||
| 8 | syncShortcutModifierStateFromKeyboardEvent, | ||
| 9 | } from "../shortcutModifierState"; | ||
| 6 | 10 | ||
| 7 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; | 11 | const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; |
| 8 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | 12 | const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; |
| 9 | const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | 13 | const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; |
| 10 | |||
| 11 | export function AppSidebarLayout({ children }: { children: ReactNode }) { | 14 | export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 12 | const navigate = useNavigate(); | 15 | const navigate = useNavigate(); |
| 13 | 16 | ||
| 17 | useEffect(() => { | ||
| 18 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 19 | syncShortcutModifierStateFromKeyboardEvent(event); | ||
| 20 | }; | ||
| 21 | const onWindowKeyUp = (event: KeyboardEvent) => { | ||
| 22 | syncShortcutModifierStateFromKeyboardEvent(event); | ||
| 23 | }; | ||
| 24 | const onWindowBlur = () => { | ||
| 25 | clearShortcutModifierState(); | ||
| 26 | }; | ||
| 27 | |||
| 28 | window.addEventListener("keydown", onWindowKeyDown, true); | ||
| 29 | window.addEventListener("keyup", onWindowKeyUp, true); | ||
| 30 | window.addEventListener("blur", onWindowBlur); | ||
| 31 | |||
| 32 | return () => { | ||
| 33 | window.removeEventListener("keydown", onWindowKeyDown, true); | ||
| 34 | window.removeEventListener("keyup", onWindowKeyUp, true); | ||
| 35 | window.removeEventListener("blur", onWindowBlur); | ||
| 36 | }; | ||
| 37 | }, []); | ||
| 38 | |||
| 14 | useEffect(() => { | 39 | useEffect(() => { |
| 15 | const onMenuAction = window.desktopBridge?.onMenuAction; | 40 | const onMenuAction = window.desktopBridge?.onMenuAction; |
| 16 | if (typeof onMenuAction !== "function") { | 41 | if (typeof onMenuAction !== "function") { |
| ... | @@ -18,8 +43,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -18,8 +43,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 18 | } | 43 | } |
| 19 | 44 | ||
| 20 | const unsubscribe = onMenuAction((action) => { | 45 | const unsubscribe = onMenuAction((action) => { |
| 21 | if (action !== "open-settings") return; | 46 | if (action === "open-settings") { |
| 22 | void navigate({ to: "/settings" }); | 47 | void navigate({ to: "/settings" }); |
| 48 | } | ||
| 23 | }); | 49 | }); |
| 24 | 50 | ||
| 25 | return () => { | 51 | return () => { |
apps/web/src/components/ChatView.browser.tsx+237| ... | @@ -1384,6 +1384,18 @@ function dispatchChatNewShortcut(): void { | ... | @@ -1384,6 +1384,18 @@ function dispatchChatNewShortcut(): void { |
| 1384 | ); | 1384 | ); |
| 1385 | } | 1385 | } |
| 1386 | 1386 | ||
| 1387 | function releaseModShortcut(key?: string): void { | ||
| 1388 | window.dispatchEvent( | ||
| 1389 | new KeyboardEvent("keyup", { | ||
| 1390 | key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), | ||
| 1391 | metaKey: false, | ||
| 1392 | ctrlKey: false, | ||
| 1393 | bubbles: true, | ||
| 1394 | cancelable: true, | ||
| 1395 | }), | ||
| 1396 | ); | ||
| 1397 | } | ||
| 1398 | |||
| 1387 | async function triggerChatNewShortcutUntilPath( | 1399 | async function triggerChatNewShortcutUntilPath( |
| 1388 | router: ReturnType<typeof getRouter>, | 1400 | router: ReturnType<typeof getRouter>, |
| 1389 | predicate: (pathname: string) => boolean, | 1401 | predicate: (pathname: string) => boolean, |
| ... | @@ -4006,6 +4018,29 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -4006,6 +4018,29 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 4006 | node: { type: "identifier", name: "terminalFocus" }, | 4018 | node: { type: "identifier", name: "terminalFocus" }, |
| 4007 | }, | 4019 | }, |
| 4008 | }, | 4020 | }, |
| 4021 | { | ||
| 4022 | command: "thread.jump.1", | ||
| 4023 | shortcut: { | ||
| 4024 | key: "1", | ||
| 4025 | metaKey: true, | ||
| 4026 | ctrlKey: false, | ||
| 4027 | shiftKey: false, | ||
| 4028 | altKey: false, | ||
| 4029 | modKey: false, | ||
| 4030 | }, | ||
| 4031 | }, | ||
| 4032 | { | ||
| 4033 | command: "modelPicker.jump.1", | ||
| 4034 | shortcut: { | ||
| 4035 | key: "1", | ||
| 4036 | metaKey: true, | ||
| 4037 | ctrlKey: false, | ||
| 4038 | shiftKey: false, | ||
| 4039 | altKey: false, | ||
| 4040 | modKey: false, | ||
| 4041 | }, | ||
| 4042 | whenAst: { type: "identifier", name: "modelPickerOpen" }, | ||
| 4043 | }, | ||
| 4009 | ], | 4044 | ], |
| 4010 | }; | 4045 | }; |
| 4011 | }, | 4046 | }, |
| ... | @@ -5520,6 +5555,208 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -5520,6 +5555,208 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 5520 | } | 5555 | } |
| 5521 | }); | 5556 | }); |
| 5522 | 5557 | ||
| 5558 | it("opens the model picker when selecting /model", async () => { | ||
| 5559 | const mounted = await mountChatView({ | ||
| 5560 | viewport: DEFAULT_VIEWPORT, | ||
| 5561 | snapshot: createSnapshotForTargetUser({ | ||
| 5562 | targetMessageId: "msg-user-model-command-target" as MessageId, | ||
| 5563 | targetText: "model command thread", | ||
| 5564 | }), | ||
| 5565 | }); | ||
| 5566 | |||
| 5567 | try { | ||
| 5568 | await waitForComposerEditor(); | ||
| 5569 | await page.getByTestId("composer-editor").fill("/mod"); | ||
| 5570 | |||
| 5571 | const menuItem = await waitForComposerMenuItem("slash:model"); | ||
| 5572 | await menuItem.click(); | ||
| 5573 | |||
| 5574 | await vi.waitFor(() => { | ||
| 5575 | expect(document.querySelector(".model-picker-list")).not.toBeNull(); | ||
| 5576 | expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); | ||
| 5577 | }); | ||
| 5578 | |||
| 5579 | await new Promise<void>((resolve) => { | ||
| 5580 | requestAnimationFrame(() => { | ||
| 5581 | requestAnimationFrame(() => resolve()); | ||
| 5582 | }); | ||
| 5583 | }); | ||
| 5584 | |||
| 5585 | await vi.waitFor(() => { | ||
| 5586 | const searchInput = document.querySelector<HTMLInputElement>( | ||
| 5587 | 'input[placeholder="Search models..."]', | ||
| 5588 | ); | ||
| 5589 | expect(searchInput).not.toBeNull(); | ||
| 5590 | expect(document.activeElement).toBe(searchInput); | ||
| 5591 | }); | ||
| 5592 | } finally { | ||
| 5593 | await mounted.cleanup(); | ||
| 5594 | } | ||
| 5595 | }); | ||
| 5596 | |||
| 5597 | it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { | ||
| 5598 | const snapshot = createSnapshotForTargetUser({ | ||
| 5599 | targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, | ||
| 5600 | targetText: "model picker shortcut thread", | ||
| 5601 | }); | ||
| 5602 | const mounted = await mountChatView({ | ||
| 5603 | viewport: DEFAULT_VIEWPORT, | ||
| 5604 | snapshot: { | ||
| 5605 | ...snapshot, | ||
| 5606 | projects: snapshot.projects.map((project) => | ||
| 5607 | project.id === PROJECT_ID | ||
| 5608 | ? Object.assign({}, project, { | ||
| 5609 | defaultModelSelection: { provider: "codex", model: "gpt-5.4" }, | ||
| 5610 | }) | ||
| 5611 | : project, | ||
| 5612 | ), | ||
| 5613 | threads: snapshot.threads.map((thread) => | ||
| 5614 | thread.id === THREAD_ID | ||
| 5615 | ? Object.assign({}, thread, { | ||
| 5616 | modelSelection: { provider: "codex", model: "gpt-5.4" }, | ||
| 5617 | }) | ||
| 5618 | : thread, | ||
| 5619 | ), | ||
| 5620 | }, | ||
| 5621 | configureFixture: (nextFixture) => { | ||
| 5622 | nextFixture.serverConfig = { | ||
| 5623 | ...nextFixture.serverConfig, | ||
| 5624 | keybindings: [ | ||
| 5625 | { | ||
| 5626 | command: "modelPicker.toggle", | ||
| 5627 | shortcut: { | ||
| 5628 | key: "m", | ||
| 5629 | metaKey: false, | ||
| 5630 | ctrlKey: true, | ||
| 5631 | shiftKey: true, | ||
| 5632 | altKey: false, | ||
| 5633 | modKey: false, | ||
| 5634 | }, | ||
| 5635 | whenAst: { | ||
| 5636 | type: "not", | ||
| 5637 | node: { type: "identifier", name: "terminalFocus" }, | ||
| 5638 | }, | ||
| 5639 | }, | ||
| 5640 | { | ||
| 5641 | command: "thread.jump.1", | ||
| 5642 | shortcut: { | ||
| 5643 | key: "1", | ||
| 5644 | metaKey: false, | ||
| 5645 | ctrlKey: true, | ||
| 5646 | shiftKey: false, | ||
| 5647 | altKey: false, | ||
| 5648 | modKey: false, | ||
| 5649 | }, | ||
| 5650 | }, | ||
| 5651 | { | ||
| 5652 | command: "modelPicker.jump.1", | ||
| 5653 | shortcut: { | ||
| 5654 | key: "1", | ||
| 5655 | metaKey: false, | ||
| 5656 | ctrlKey: true, | ||
| 5657 | shiftKey: false, | ||
| 5658 | altKey: false, | ||
| 5659 | modKey: false, | ||
| 5660 | }, | ||
| 5661 | whenAst: { type: "identifier", name: "modelPickerOpen" }, | ||
| 5662 | }, | ||
| 5663 | ], | ||
| 5664 | providers: [ | ||
| 5665 | { | ||
| 5666 | ...nextFixture.serverConfig.providers[0]!, | ||
| 5667 | provider: "codex", | ||
| 5668 | models: [ | ||
| 5669 | { | ||
| 5670 | slug: "gpt-5.1-codex-max", | ||
| 5671 | name: "GPT-5.1 Codex Max", | ||
| 5672 | isCustom: false, | ||
| 5673 | capabilities: { | ||
| 5674 | supportsFastMode: true, | ||
| 5675 | supportsThinkingToggle: false, | ||
| 5676 | reasoningEffortLevels: [], | ||
| 5677 | promptInjectedEffortLevels: [], | ||
| 5678 | contextWindowOptions: [], | ||
| 5679 | }, | ||
| 5680 | }, | ||
| 5681 | { | ||
| 5682 | slug: "gpt-5.3-codex", | ||
| 5683 | name: "GPT-5.3 Codex", | ||
| 5684 | isCustom: false, | ||
| 5685 | capabilities: { | ||
| 5686 | supportsFastMode: true, | ||
| 5687 | supportsThinkingToggle: false, | ||
| 5688 | reasoningEffortLevels: [], | ||
| 5689 | promptInjectedEffortLevels: [], | ||
| 5690 | contextWindowOptions: [], | ||
| 5691 | }, | ||
| 5692 | }, | ||
| 5693 | { | ||
| 5694 | slug: "gpt-5.4", | ||
| 5695 | name: "GPT-5.4", | ||
| 5696 | isCustom: false, | ||
| 5697 | capabilities: { | ||
| 5698 | supportsFastMode: true, | ||
| 5699 | supportsThinkingToggle: false, | ||
| 5700 | reasoningEffortLevels: [], | ||
| 5701 | promptInjectedEffortLevels: [], | ||
| 5702 | contextWindowOptions: [], | ||
| 5703 | }, | ||
| 5704 | }, | ||
| 5705 | ], | ||
| 5706 | }, | ||
| 5707 | ], | ||
| 5708 | }; | ||
| 5709 | }, | ||
| 5710 | }); | ||
| 5711 | |||
| 5712 | try { | ||
| 5713 | await waitForServerConfigToApply(); | ||
| 5714 | await waitForComposerEditor(); | ||
| 5715 | |||
| 5716 | const initialPath = mounted.router.state.location.pathname; | ||
| 5717 | window.dispatchEvent( | ||
| 5718 | new KeyboardEvent("keydown", { | ||
| 5719 | key: "m", | ||
| 5720 | ctrlKey: true, | ||
| 5721 | shiftKey: true, | ||
| 5722 | bubbles: true, | ||
| 5723 | cancelable: true, | ||
| 5724 | }), | ||
| 5725 | ); | ||
| 5726 | |||
| 5727 | await vi.waitFor(() => { | ||
| 5728 | expect(document.querySelector(".model-picker-list")).not.toBeNull(); | ||
| 5729 | }); | ||
| 5730 | |||
| 5731 | const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; | ||
| 5732 | await vi.waitFor(() => { | ||
| 5733 | expect( | ||
| 5734 | Array.from( | ||
| 5735 | document.querySelectorAll<HTMLElement>('.model-picker-list [data-slot="kbd"]'), | ||
| 5736 | ).some((element) => element.textContent?.trim() === jumpLabel), | ||
| 5737 | ).toBe(true); | ||
| 5738 | }); | ||
| 5739 | expect(mounted.router.state.location.pathname).toBe(initialPath); | ||
| 5740 | |||
| 5741 | window.dispatchEvent( | ||
| 5742 | new KeyboardEvent("keydown", { | ||
| 5743 | key: "m", | ||
| 5744 | ctrlKey: true, | ||
| 5745 | shiftKey: true, | ||
| 5746 | bubbles: true, | ||
| 5747 | cancelable: true, | ||
| 5748 | }), | ||
| 5749 | ); | ||
| 5750 | |||
| 5751 | await vi.waitFor(() => { | ||
| 5752 | expect(document.querySelector(".model-picker-list")).toBeNull(); | ||
| 5753 | }); | ||
| 5754 | } finally { | ||
| 5755 | releaseModShortcut("Control"); | ||
| 5756 | await mounted.cleanup(); | ||
| 5757 | } | ||
| 5758 | }); | ||
| 5759 | |||
| 5523 | it("shows a tooltip with the skill description when hovering a skill pill", async () => { | 5760 | it("shows a tooltip with the skill description when hovering a skill pill", async () => { |
| 5524 | const mounted = await mountChatView({ | 5761 | const mounted = await mountChatView({ |
| 5525 | viewport: DEFAULT_VIEWPORT, | 5762 | viewport: DEFAULT_VIEWPORT, |
apps/web/src/components/ChatView.tsx+10| ... | @@ -2233,6 +2233,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2233,6 +2233,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2233 | const shortcutContext = { | 2233 | const shortcutContext = { |
| 2234 | terminalFocus: isTerminalFocused(), | 2234 | terminalFocus: isTerminalFocused(), |
| 2235 | terminalOpen: Boolean(terminalState.terminalOpen), | 2235 | terminalOpen: Boolean(terminalState.terminalOpen), |
| 2236 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | ||
| 2236 | }; | 2237 | }; |
| 2237 | 2238 | ||
| 2238 | const command = resolveShortcutCommand(event, keybindings, { | 2239 | const command = resolveShortcutCommand(event, keybindings, { |
| ... | @@ -2282,6 +2283,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2282,6 +2283,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2282 | return; | 2283 | return; |
| 2283 | } | 2284 | } |
| 2284 | 2285 | ||
| 2286 | if (command === "modelPicker.toggle") { | ||
| 2287 | event.preventDefault(); | ||
| 2288 | event.stopPropagation(); | ||
| 2289 | composerRef.current?.toggleModelPicker(); | ||
| 2290 | return; | ||
| 2291 | } | ||
| 2292 | |||
| 2285 | const scriptId = projectScriptIdFromCommand(command); | 2293 | const scriptId = projectScriptIdFromCommand(command); |
| 2286 | if (!scriptId || !activeProject) return; | 2294 | if (!scriptId || !activeProject) return; |
| 2287 | const script = activeProject.scripts.find((entry) => entry.id === scriptId); | 2295 | const script = activeProject.scripts.find((entry) => entry.id === scriptId); |
| ... | @@ -3341,6 +3349,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -3341,6 +3349,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3341 | activeThreadActivities={activeThread?.activities} | 3349 | activeThreadActivities={activeThread?.activities} |
| 3342 | resolvedTheme={resolvedTheme} | 3350 | resolvedTheme={resolvedTheme} |
| 3343 | settings={settings} | 3351 | settings={settings} |
| 3352 | keybindings={keybindings} | ||
| 3353 | terminalOpen={Boolean(terminalState.terminalOpen)} | ||
| 3344 | gitCwd={gitCwd} | 3354 | gitCwd={gitCwd} |
| 3345 | promptRef={promptRef} | 3355 | promptRef={promptRef} |
| 3346 | composerImagesRef={composerImagesRef} | 3356 | composerImagesRef={composerImagesRef} |
apps/web/src/components/CommandPalette.logic.ts+19-15| ... | @@ -151,22 +151,26 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr | ... | @@ -151,22 +151,26 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr |
| 151 | const leadingContent = input.renderLeadingContent?.(thread); | 151 | const leadingContent = input.renderLeadingContent?.(thread); |
| 152 | const trailingContent = input.renderTrailingContent?.(thread); | 152 | const trailingContent = input.renderTrailingContent?.(thread); |
| 153 | 153 | ||
| 154 | return { | 154 | return Object.assign( |
| 155 | kind: "action", | 155 | { |
| 156 | value: `thread:${thread.id}`, | 156 | kind: "action" as const, |
| 157 | searchTerms: [thread.title, projectTitle ?? "", thread.branch ?? ""], | 157 | value: `thread:${thread.id}`, |
| 158 | title: thread.title, | 158 | searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``], |
| 159 | description: descriptionParts.join(" · "), | 159 | title: thread.title, |
| 160 | timestamp: formatRelativeTimeLabel( | 160 | description: descriptionParts.join(` · `), |
| 161 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, | 161 | timestamp: formatRelativeTimeLabel( |
| 162 | ), | 162 | thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, |
| 163 | icon: input.icon, | 163 | ), |
| 164 | ...(leadingContent ? { titleLeadingContent: leadingContent } : {}), | 164 | icon: input.icon, |
| 165 | ...(trailingContent ? { titleTrailingContent: trailingContent } : {}), | 165 | }, |
| 166 | run: async () => { | 166 | leadingContent ? { titleLeadingContent: leadingContent } : {}, |
| 167 | await input.runThread(thread); | 167 | trailingContent ? { titleTrailingContent: trailingContent } : {}, |
| 168 | { | ||
| 169 | run: async () => { | ||
| 170 | await input.runThread(thread); | ||
| 171 | }, | ||
| 168 | }, | 172 | }, |
| 169 | }; | 173 | ); |
| 170 | }); | 174 | }); |
| 171 | } | 175 | } |
| 172 | 176 |
apps/web/src/components/Icons.tsx+33-10| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { type SVGProps, useId } from "react"; | 1 | import { type SVGProps, useId } from "react"; |
| 2 | import { cn } from "~/lib/utils"; | ||
| 2 | 3 | ||
| 3 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; | 4 | export type Icon = React.FC<SVGProps<SVGSVGElement>>; |
| 4 | 5 | ||
| ... | @@ -14,8 +15,12 @@ export const GitHubIcon: Icon = (props) => ( | ... | @@ -14,8 +15,12 @@ export const GitHubIcon: Icon = (props) => ( |
| 14 | </svg> | 15 | </svg> |
| 15 | ); | 16 | ); |
| 16 | 17 | ||
| 17 | export const CursorIcon: Icon = (props) => ( | 18 | export const CursorIcon: Icon = ({ className, ...props }) => ( |
| 18 | <svg {...props} viewBox="0 0 466.73 532.09" fill="currentColor"> | 19 | <svg |
| 20 | {...props} | ||
| 21 | viewBox="0 0 466.73 532.09" | ||
| 22 | className={cn("fill-[#26251E] dark:fill-[#EDECEC]", className)} | ||
| 23 | > | ||
| 19 | <path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" /> | 24 | <path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z" /> |
| 20 | </svg> | 25 | </svg> |
| 21 | ); | 26 | ); |
| ... | @@ -290,18 +295,25 @@ export const Zed: Icon = (props) => { | ... | @@ -290,18 +295,25 @@ export const Zed: Icon = (props) => { |
| 290 | ); | 295 | ); |
| 291 | }; | 296 | }; |
| 292 | 297 | ||
| 293 | export const OpenAI: Icon = (props) => ( | 298 | export const OpenAI: Icon = ({ className, ...props }) => ( |
| 294 | <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 260" fill="currentColor"> | 299 | <svg |
| 300 | {...props} | ||
| 301 | preserveAspectRatio="xMidYMid" | ||
| 302 | viewBox="0 0 256 260" | ||
| 303 | className={cn("fill-black dark:fill-white", className)} | ||
| 304 | > | ||
| 295 | <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" /> | 305 | <path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" /> |
| 296 | </svg> | 306 | </svg> |
| 297 | ); | 307 | ); |
| 298 | 308 | ||
| 299 | export const ClaudeAI: Icon = (props) => ( | 309 | export const ClaudeAI: Icon = ({ className, ...props }) => ( |
| 300 | <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"> | 310 | <svg |
| 301 | <path | 311 | {...props} |
| 302 | fill="currentColor" | 312 | preserveAspectRatio="xMidYMid" |
| 303 | d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" | 313 | viewBox="0 0 256 257" |
| 304 | /> | 314 | className={cn("fill-[#d97757]", className)} |
| 315 | > | ||
| 316 | <path d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" /> | ||
| 305 | </svg> | 317 | </svg> |
| 306 | ); | 318 | ); |
| 307 | 319 | ||
| ... | @@ -565,3 +577,14 @@ export const OpenCodeIcon: Icon = (props) => ( | ... | @@ -565,3 +577,14 @@ export const OpenCodeIcon: Icon = (props) => ( |
| 565 | </defs> | 577 | </defs> |
| 566 | </svg> | 578 | </svg> |
| 567 | ); | 579 | ); |
| 580 | |||
| 581 | export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( | ||
| 582 | <svg | ||
| 583 | {...props} | ||
| 584 | preserveAspectRatio="xMidYMid" | ||
| 585 | viewBox="0 0 256 208" | ||
| 586 | className={cn("fill-black dark:fill-white", className)} | ||
| 587 | > | ||
| 588 | <path d="M205.3 31.4c14 14.8 20 35.2 22.5 63.6 6.6 0 12.8 1.5 17 7.2l7.8 10.6c2.2 3 3.4 6.6 3.4 10.4v28.7a12 12 0 0 1-4.8 9.5C215.9 187.2 172.3 208 128 208c-49 0-98.2-28.3-123.2-46.6a12 12 0 0 1-4.8-9.5v-28.7c0-3.8 1.2-7.4 3.4-10.5l7.8-10.5c4.2-5.7 10.4-7.2 17-7.2 2.5-28.4 8.4-48.8 22.5-63.6C77.3 3.2 112.6 0 127.6 0h.4c14.7 0 50.4 2.9 77.3 31.4ZM128 78.7c-3 0-6.5.2-10.3.6a27.1 27.1 0 0 1-6 12.1 45 45 0 0 1-32 13c-6.8 0-13.9-1.5-19.7-5.2-5.5 1.9-10.8 4.5-11.2 11-.5 12.2-.6 24.5-.6 36.8 0 6.1 0 12.3-.2 18.5 0 3.6 2.2 6.9 5.5 8.4C79.9 185.9 105 192 128 192s48-6 74.5-18.1a9.4 9.4 0 0 0 5.5-8.4c.3-18.4 0-37-.8-55.3-.4-6.6-5.7-9.1-11.2-11-5.8 3.7-13 5.1-19.7 5.1a45 45 0 0 1-32-12.9 27.1 27.1 0 0 1-6-12.1c-3.4-.4-6.9-.5-10.3-.6Zm-27 44c5.8 0 10.5 4.6 10.5 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.6-10.4 10.4-10.4Zm53.4 0c5.8 0 10.4 4.6 10.4 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.7-10.4 10.4-10.4Zm-73-94.4c-11.2 1.1-20.6 4.8-25.4 10-10.4 11.3-8.2 40.1-2.2 46.2A31.2 31.2 0 0 0 75 91.7c6.8 0 19.6-1.5 30.1-12.2 4.7-4.5 7.5-15.7 7.2-27-.3-9.1-2.9-16.7-6.7-19.9-4.2-3.6-13.6-5.2-24.2-4.3Zm69 4.3c-3.8 3.2-6.4 10.8-6.7 19.9-.3 11.3 2.5 22.5 7.2 27a41.7 41.7 0 0 0 30 12.2c8.9 0 17-2.9 21.3-7.2 6-6.1 8.2-34.9-2.2-46.3-4.8-5-14.2-8.8-25.4-9.9-10.6-1-20 .7-24.2 4.3ZM128 56c-2.6 0-5.6.2-9 .5.4 1.7.5 3.7.7 5.7 0 1.5 0 3-.2 4.5 3.2-.3 6-.3 8.5-.3 2.6 0 5.3 0 8.5.3-.2-1.6-.2-3-.2-4.5.2-2 .3-4 .7-5.7-3.4-.3-6.4-.5-9-.5Z" /> | ||
| 589 | </svg> | ||
| 590 | ); |
apps/web/src/components/Sidebar.tsx+41-104| ... | @@ -75,11 +75,13 @@ import { useUiStateStore } from "../uiStateStore"; | ... | @@ -75,11 +75,13 @@ import { useUiStateStore } from "../uiStateStore"; |
| 75 | import { | 75 | import { |
| 76 | resolveShortcutCommand, | 76 | resolveShortcutCommand, |
| 77 | shortcutLabelForCommand, | 77 | shortcutLabelForCommand, |
| 78 | shouldShowThreadJumpHints, | 78 | shouldShowThreadJumpHintsForModifiers, |
| 79 | threadJumpCommandForIndex, | 79 | threadJumpCommandForIndex, |
| 80 | threadJumpIndexFromCommand, | 80 | threadJumpIndexFromCommand, |
| 81 | threadTraversalDirectionFromCommand, | 81 | threadTraversalDirectionFromCommand, |
| 82 | } from "../keybindings"; | 82 | } from "../keybindings"; |
| 83 | import { useModelPickerOpen } from "../modelPickerOpenState"; | ||
| 84 | import { useShortcutModifierState } from "../shortcutModifierState"; | ||
| 83 | import { useGitStatus } from "../lib/gitStatusState"; | 85 | import { useGitStatus } from "../lib/gitStatusState"; |
| 84 | import { readLocalApi } from "../localApi"; | 86 | import { readLocalApi } from "../localApi"; |
| 85 | import { useComposerDraftStore } from "../composerDraftStore"; | 87 | import { useComposerDraftStore } from "../composerDraftStore"; |
| ... | @@ -199,24 +201,6 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = | ... | @@ -199,24 +201,6 @@ const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = |
| 199 | separate: "Keep separate", | 201 | separate: "Keep separate", |
| 200 | }; | 202 | }; |
| 201 | 203 | ||
| 202 | function threadJumpLabelMapsEqual( | ||
| 203 | left: ReadonlyMap<string, string>, | ||
| 204 | right: ReadonlyMap<string, string>, | ||
| 205 | ): boolean { | ||
| 206 | if (left === right) { | ||
| 207 | return true; | ||
| 208 | } | ||
| 209 | if (left.size !== right.size) { | ||
| 210 | return false; | ||
| 211 | } | ||
| 212 | for (const [key, value] of left) { | ||
| 213 | if (right.get(key) !== value) { | ||
| 214 | return false; | ||
| 215 | } | ||
| 216 | } | ||
| 217 | return true; | ||
| 218 | } | ||
| 219 | |||
| 220 | function formatProjectMemberActionLabel( | 204 | function formatProjectMemberActionLabel( |
| 221 | member: SidebarProjectGroupMember, | 205 | member: SidebarProjectGroupMember, |
| 222 | groupedProjectCount: number, | 206 | groupedProjectCount: number, |
| ... | @@ -2715,6 +2699,8 @@ export default function Sidebar() { | ... | @@ -2715,6 +2699,8 @@ export default function Sidebar() { |
| 2715 | const clearSelection = useThreadSelectionStore((s) => s.clearSelection); | 2699 | const clearSelection = useThreadSelectionStore((s) => s.clearSelection); |
| 2716 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); | 2700 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); |
| 2717 | const platform = navigator.platform; | 2701 | const platform = navigator.platform; |
| 2702 | const shortcutModifiers = useShortcutModifierState(); | ||
| 2703 | const modelPickerOpen = useModelPickerOpen(); | ||
| 2718 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 2704 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 2719 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); | 2705 | const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); |
| 2720 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); | 2706 | const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); |
| ... | @@ -2822,8 +2808,9 @@ export default function Sidebar() { | ... | @@ -2822,8 +2808,9 @@ export default function Sidebar() { |
| 2822 | routeThreadRef, | 2808 | routeThreadRef, |
| 2823 | ).terminalOpen | 2809 | ).terminalOpen |
| 2824 | : false, | 2810 | : false, |
| 2811 | modelPickerOpen, | ||
| 2825 | }), | 2812 | }), |
| 2826 | [routeThreadRef], | 2813 | [modelPickerOpen, routeThreadRef], |
| 2827 | ); | 2814 | ); |
| 2828 | const newThreadShortcutLabelOptions = useMemo( | 2815 | const newThreadShortcutLabelOptions = useMemo( |
| 2829 | () => ({ | 2816 | () => ({ |
| ... | @@ -3016,12 +3003,37 @@ export default function Sidebar() { | ... | @@ -3016,12 +3003,37 @@ export default function Sidebar() { |
| 3016 | () => [...threadJumpCommandByKey.keys()], | 3003 | () => [...threadJumpCommandByKey.keys()], |
| 3017 | [threadJumpCommandByKey], | 3004 | [threadJumpCommandByKey], |
| 3018 | ); | 3005 | ); |
| 3019 | const [threadJumpLabelByKey, setThreadJumpLabelByKey] = | 3006 | const sidebarShortcutContext = useMemo( |
| 3020 | useState<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS); | 3007 | () => ({ |
| 3021 | const threadJumpLabelsRef = useRef<ReadonlyMap<string, string>>(EMPTY_THREAD_JUMP_LABELS); | 3008 | terminalFocus: false, |
| 3022 | threadJumpLabelsRef.current = threadJumpLabelByKey; | 3009 | terminalOpen: routeThreadRef |
| 3023 | const showThreadJumpHintsRef = useRef(showThreadJumpHints); | 3010 | ? selectThreadTerminalState( |
| 3024 | showThreadJumpHintsRef.current = showThreadJumpHints; | 3011 | useTerminalStateStore.getState().terminalStateByThreadKey, |
| 3012 | routeThreadRef, | ||
| 3013 | ).terminalOpen | ||
| 3014 | : false, | ||
| 3015 | modelPickerOpen, | ||
| 3016 | }), | ||
| 3017 | [modelPickerOpen, routeThreadRef], | ||
| 3018 | ); | ||
| 3019 | const threadJumpLabelByKey = useMemo( | ||
| 3020 | () => | ||
| 3021 | buildThreadJumpLabelMap({ | ||
| 3022 | keybindings, | ||
| 3023 | platform, | ||
| 3024 | terminalOpen: sidebarShortcutContext.terminalOpen, | ||
| 3025 | threadJumpCommandByKey, | ||
| 3026 | }), | ||
| 3027 | [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], | ||
| 3028 | ); | ||
| 3029 | const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( | ||
| 3030 | shortcutModifiers, | ||
| 3031 | keybindings, | ||
| 3032 | { | ||
| 3033 | platform, | ||
| 3034 | context: sidebarShortcutContext, | ||
| 3035 | }, | ||
| 3036 | ); | ||
| 3025 | const visibleThreadJumpLabelByKey = showThreadJumpHints | 3037 | const visibleThreadJumpLabelByKey = showThreadJumpHints |
| 3026 | ? threadJumpLabelByKey | 3038 | ? threadJumpLabelByKey |
| 3027 | : EMPTY_THREAD_JUMP_LABELS; | 3039 | : EMPTY_THREAD_JUMP_LABELS; |
| ... | @@ -3052,52 +3064,12 @@ export default function Sidebar() { | ... | @@ -3052,52 +3064,12 @@ export default function Sidebar() { |
| 3052 | }, [prewarmedSidebarThreadRefs]); | 3064 | }, [prewarmedSidebarThreadRefs]); |
| 3053 | 3065 | ||
| 3054 | useEffect(() => { | 3066 | useEffect(() => { |
| 3055 | const clearThreadJumpHints = () => { | 3067 | updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); |
| 3056 | setThreadJumpLabelByKey((current) => | 3068 | }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); |
| 3057 | current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, | ||
| 3058 | ); | ||
| 3059 | updateThreadJumpHintsVisibility(false); | ||
| 3060 | }; | ||
| 3061 | const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => | ||
| 3062 | !event.metaKey && | ||
| 3063 | !event.ctrlKey && | ||
| 3064 | !event.altKey && | ||
| 3065 | !event.shiftKey && | ||
| 3066 | event.key !== "Meta" && | ||
| 3067 | event.key !== "Control" && | ||
| 3068 | event.key !== "Alt" && | ||
| 3069 | event.key !== "Shift" && | ||
| 3070 | !showThreadJumpHintsRef.current && | ||
| 3071 | threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; | ||
| 3072 | 3069 | ||
| 3070 | useEffect(() => { | ||
| 3073 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | 3071 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { |
| 3074 | if (shouldIgnoreThreadJumpHintUpdate(event)) { | ||
| 3075 | return; | ||
| 3076 | } | ||
| 3077 | const shortcutContext = getCurrentSidebarShortcutContext(); | 3072 | const shortcutContext = getCurrentSidebarShortcutContext(); |
| 3078 | const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { | ||
| 3079 | platform, | ||
| 3080 | context: shortcutContext, | ||
| 3081 | }); | ||
| 3082 | if (!shouldShowHints) { | ||
| 3083 | if ( | ||
| 3084 | showThreadJumpHintsRef.current || | ||
| 3085 | threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS | ||
| 3086 | ) { | ||
| 3087 | clearThreadJumpHints(); | ||
| 3088 | } | ||
| 3089 | } else { | ||
| 3090 | setThreadJumpLabelByKey((current) => { | ||
| 3091 | const nextLabelMap = buildThreadJumpLabelMap({ | ||
| 3092 | keybindings, | ||
| 3093 | platform, | ||
| 3094 | terminalOpen: shortcutContext.terminalOpen, | ||
| 3095 | threadJumpCommandByKey, | ||
| 3096 | }); | ||
| 3097 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | ||
| 3098 | }); | ||
| 3099 | updateThreadJumpHintsVisibility(true); | ||
| 3100 | } | ||
| 3101 | 3073 | ||
| 3102 | if (event.defaultPrevented || event.repeat) { | 3074 | if (event.defaultPrevented || event.repeat) { |
| 3103 | return; | 3075 | return; |
| ... | @@ -3147,43 +3119,10 @@ export default function Sidebar() { | ... | @@ -3147,43 +3119,10 @@ export default function Sidebar() { |
| 3147 | navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); | 3119 | navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); |
| 3148 | }; | 3120 | }; |
| 3149 | 3121 | ||
| 3150 | const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { | ||
| 3151 | if (shouldIgnoreThreadJumpHintUpdate(event)) { | ||
| 3152 | return; | ||
| 3153 | } | ||
| 3154 | const shortcutContext = getCurrentSidebarShortcutContext(); | ||
| 3155 | const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { | ||
| 3156 | platform, | ||
| 3157 | context: shortcutContext, | ||
| 3158 | }); | ||
| 3159 | if (!shouldShowHints) { | ||
| 3160 | clearThreadJumpHints(); | ||
| 3161 | return; | ||
| 3162 | } | ||
| 3163 | setThreadJumpLabelByKey((current) => { | ||
| 3164 | const nextLabelMap = buildThreadJumpLabelMap({ | ||
| 3165 | keybindings, | ||
| 3166 | platform, | ||
| 3167 | terminalOpen: shortcutContext.terminalOpen, | ||
| 3168 | threadJumpCommandByKey, | ||
| 3169 | }); | ||
| 3170 | return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; | ||
| 3171 | }); | ||
| 3172 | updateThreadJumpHintsVisibility(true); | ||
| 3173 | }; | ||
| 3174 | |||
| 3175 | const onWindowBlur = () => { | ||
| 3176 | clearThreadJumpHints(); | ||
| 3177 | }; | ||
| 3178 | |||
| 3179 | window.addEventListener("keydown", onWindowKeyDown); | 3122 | window.addEventListener("keydown", onWindowKeyDown); |
| 3180 | window.addEventListener("keyup", onWindowKeyUp); | ||
| 3181 | window.addEventListener("blur", onWindowBlur); | ||
| 3182 | 3123 | ||
| 3183 | return () => { | 3124 | return () => { |
| 3184 | window.removeEventListener("keydown", onWindowKeyDown); | 3125 | window.removeEventListener("keydown", onWindowKeyDown); |
| 3185 | window.removeEventListener("keyup", onWindowKeyUp); | ||
| 3186 | window.removeEventListener("blur", onWindowBlur); | ||
| 3187 | }; | 3126 | }; |
| 3188 | }, [ | 3127 | }, [ |
| 3189 | getCurrentSidebarShortcutContext, | 3128 | getCurrentSidebarShortcutContext, |
| ... | @@ -3193,9 +3132,7 @@ export default function Sidebar() { | ... | @@ -3193,9 +3132,7 @@ export default function Sidebar() { |
| 3193 | platform, | 3132 | platform, |
| 3194 | routeThreadKey, | 3133 | routeThreadKey, |
| 3195 | sidebarThreadByKey, | 3134 | sidebarThreadByKey, |
| 3196 | threadJumpCommandByKey, | ||
| 3197 | threadJumpThreadKeys, | 3135 | threadJumpThreadKeys, |
| 3198 | updateThreadJumpHintsVisibility, | ||
| 3199 | ]); | 3136 | ]); |
| 3200 | 3137 | ||
| 3201 | useEffect(() => { | 3138 | useEffect(() => { |
apps/web/src/components/chat/ChatComposer.tsx+38-72| ... | @@ -6,6 +6,7 @@ import type { | ... | @@ -6,6 +6,7 @@ import type { |
| 6 | ProviderApprovalDecision, | 6 | ProviderApprovalDecision, |
| 7 | ProviderInteractionMode, | 7 | ProviderInteractionMode, |
| 8 | ProviderKind, | 8 | ProviderKind, |
| 9 | ResolvedKeybindingsConfig, | ||
| 9 | RuntimeMode, | 10 | RuntimeMode, |
| 10 | ScopedThreadRef, | 11 | ScopedThreadRef, |
| 11 | ServerProvider, | 12 | ServerProvider, |
| ... | @@ -59,7 +60,7 @@ import { | ... | @@ -59,7 +60,7 @@ import { |
| 59 | shouldUseCompactComposerFooter, | 60 | shouldUseCompactComposerFooter, |
| 60 | } from "../composerFooterLayout"; | 61 | } from "../composerFooterLayout"; |
| 61 | import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; | 62 | import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; |
| 62 | import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; | 63 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 63 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; | 64 | import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; |
| 64 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; | 65 | import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; |
| 65 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | 66 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| ... | @@ -317,6 +318,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( | ... | @@ -317,6 +318,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 317 | export interface ChatComposerHandle { | 318 | export interface ChatComposerHandle { |
| 318 | focusAtEnd: () => void; | 319 | focusAtEnd: () => void; |
| 319 | focusAt: (cursor: number) => void; | 320 | focusAt: (cursor: number) => void; |
| 321 | openModelPicker: () => void; | ||
| 322 | toggleModelPicker: () => void; | ||
| 323 | isModelPickerOpen: () => boolean; | ||
| 320 | readSnapshot: () => { | 324 | readSnapshot: () => { |
| 321 | value: string; | 325 | value: string; |
| 322 | cursor: number; | 326 | cursor: number; |
| ... | @@ -410,6 +414,8 @@ export interface ChatComposerProps { | ... | @@ -410,6 +414,8 @@ export interface ChatComposerProps { |
| 410 | // Misc | 414 | // Misc |
| 411 | resolvedTheme: "light" | "dark"; | 415 | resolvedTheme: "light" | "dark"; |
| 412 | settings: UnifiedSettings; | 416 | settings: UnifiedSettings; |
| 417 | keybindings: ResolvedKeybindingsConfig; | ||
| 418 | terminalOpen: boolean; | ||
| 413 | gitCwd: string | null; | 419 | gitCwd: string | null; |
| 414 | 420 | ||
| 415 | // Refs the parent needs kept in sync | 421 | // Refs the parent needs kept in sync |
| ... | @@ -497,6 +503,8 @@ export const ChatComposer = memo( | ... | @@ -497,6 +503,8 @@ export const ChatComposer = memo( |
| 497 | activeThreadActivities, | 503 | activeThreadActivities, |
| 498 | resolvedTheme, | 504 | resolvedTheme, |
| 499 | settings, | 505 | settings, |
| 506 | keybindings, | ||
| 507 | terminalOpen, | ||
| 500 | gitCwd, | 508 | gitCwd, |
| 501 | promptRef, | 509 | promptRef, |
| 502 | composerImagesRef, | 510 | composerImagesRef, |
| ... | @@ -622,23 +630,6 @@ export const ChatComposer = memo( | ... | @@ -622,23 +630,6 @@ export const ChatComposer = memo( |
| 622 | ? selectedModelForPicker | 630 | ? selectedModelForPicker |
| 623 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); | 631 | : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); |
| 624 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); | 632 | }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); |
| 625 | const searchableModelOptions = useMemo( | ||
| 626 | () => | ||
| 627 | AVAILABLE_PROVIDER_OPTIONS.filter( | ||
| 628 | (option) => lockedProvider === null || option.value === lockedProvider, | ||
| 629 | ).flatMap((option) => | ||
| 630 | modelOptionsByProvider[option.value].map(({ slug, name }) => ({ | ||
| 631 | provider: option.value, | ||
| 632 | providerLabel: option.label, | ||
| 633 | slug, | ||
| 634 | name, | ||
| 635 | searchSlug: slug.toLowerCase(), | ||
| 636 | searchName: name.toLowerCase(), | ||
| 637 | searchProvider: option.label.toLowerCase(), | ||
| 638 | })), | ||
| 639 | ), | ||
| 640 | [lockedProvider, modelOptionsByProvider], | ||
| 641 | ); | ||
| 642 | 633 | ||
| 643 | // ------------------------------------------------------------------ | 634 | // ------------------------------------------------------------------ |
| 644 | // Context window | 635 | // Context window |
| ... | @@ -664,6 +655,7 @@ export const ChatComposer = memo( | ... | @@ -664,6 +655,7 @@ export const ChatComposer = memo( |
| 664 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); | 655 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 665 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); | 656 | const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); |
| 666 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); | 657 | const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); |
| 658 | const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); | ||
| 667 | 659 | ||
| 668 | // ------------------------------------------------------------------ | 660 | // ------------------------------------------------------------------ |
| 669 | // Refs | 661 | // Refs |
| ... | @@ -782,31 +774,8 @@ export const ChatComposer = memo( | ... | @@ -782,31 +774,8 @@ export const ChatComposer = memo( |
| 782 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | 774 | (skill.scope ? `${skill.scope} skill` : "Run provider skill"), |
| 783 | })); | 775 | })); |
| 784 | } | 776 | } |
| 785 | return searchableModelOptions | 777 | return []; |
| 786 | .filter(({ searchSlug, searchName, searchProvider }) => { | 778 | }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); |
| 787 | const query = composerTrigger.query.trim().toLowerCase(); | ||
| 788 | if (!query) return true; | ||
| 789 | return ( | ||
| 790 | searchSlug.includes(query) || | ||
| 791 | searchName.includes(query) || | ||
| 792 | searchProvider.includes(query) | ||
| 793 | ); | ||
| 794 | }) | ||
| 795 | .map(({ provider, providerLabel, slug, name }) => ({ | ||
| 796 | id: `model:${provider}:${slug}`, | ||
| 797 | type: "model", | ||
| 798 | provider, | ||
| 799 | model: slug, | ||
| 800 | label: name, | ||
| 801 | description: `${providerLabel} · ${slug}`, | ||
| 802 | })); | ||
| 803 | }, [ | ||
| 804 | composerTrigger, | ||
| 805 | searchableModelOptions, | ||
| 806 | selectedProvider, | ||
| 807 | selectedProviderStatus, | ||
| 808 | workspaceEntries, | ||
| 809 | ]); | ||
| 810 | 779 | ||
| 811 | const composerMenuOpen = Boolean(composerTrigger); | 780 | const composerMenuOpen = Boolean(composerTrigger); |
| 812 | const composerMenuSearchKey = composerTrigger | 781 | const composerMenuSearchKey = composerTrigger |
| ... | @@ -1276,7 +1245,7 @@ export const ChatComposer = memo( | ... | @@ -1276,7 +1245,7 @@ export const ChatComposer = memo( |
| 1276 | rangeStart: number, | 1245 | rangeStart: number, |
| 1277 | rangeEnd: number, | 1246 | rangeEnd: number, |
| 1278 | replacement: string, | 1247 | replacement: string, |
| 1279 | options?: { expectedText?: string }, | 1248 | options?: { expectedText?: string; focusEditorAfterReplace?: boolean }, |
| 1280 | ): boolean => { | 1249 | ): boolean => { |
| 1281 | const currentText = promptRef.current; | 1250 | const currentText = promptRef.current; |
| 1282 | const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); | 1251 | const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); |
| ... | @@ -1305,9 +1274,11 @@ export const ChatComposer = memo( | ... | @@ -1305,9 +1274,11 @@ export const ChatComposer = memo( |
| 1305 | } | 1274 | } |
| 1306 | setComposerCursor(nextCursor); | 1275 | setComposerCursor(nextCursor); |
| 1307 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); | 1276 | setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); |
| 1308 | window.requestAnimationFrame(() => { | 1277 | if (options?.focusEditorAfterReplace !== false) { |
| 1309 | composerEditorRef.current?.focusAt(nextCursor); | 1278 | window.requestAnimationFrame(() => { |
| 1310 | }); | 1279 | composerEditorRef.current?.focusAt(nextCursor); |
| 1280 | }); | ||
| 1281 | } | ||
| 1311 | return true; | 1282 | return true; |
| 1312 | }, | 1283 | }, |
| 1313 | [ | 1284 | [ |
| ... | @@ -1377,20 +1348,13 @@ export const ChatComposer = memo( | ... | @@ -1377,20 +1348,13 @@ export const ChatComposer = memo( |
| 1377 | } | 1348 | } |
| 1378 | if (item.type === "slash-command") { | 1349 | if (item.type === "slash-command") { |
| 1379 | if (item.command === "model") { | 1350 | if (item.command === "model") { |
| 1380 | const replacement = "/model "; | 1351 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { |
| 1381 | const replacementRangeEnd = extendReplacementRangeForTrailingSpace( | 1352 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), |
| 1382 | snapshot.value, | 1353 | focusEditorAfterReplace: false, |
| 1383 | trigger.rangeEnd, | 1354 | }); |
| 1384 | replacement, | ||
| 1385 | ); | ||
| 1386 | const applied = applyPromptReplacement( | ||
| 1387 | trigger.rangeStart, | ||
| 1388 | replacementRangeEnd, | ||
| 1389 | replacement, | ||
| 1390 | { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, | ||
| 1391 | ); | ||
| 1392 | if (applied) { | 1355 | if (applied) { |
| 1393 | setComposerHighlightedItemId(null); | 1356 | setComposerHighlightedItemId(null); |
| 1357 | setIsComposerModelPickerOpen(true); | ||
| 1394 | } | 1358 | } |
| 1395 | return; | 1359 | return; |
| 1396 | } | 1360 | } |
| ... | @@ -1439,20 +1403,8 @@ export const ChatComposer = memo( | ... | @@ -1439,20 +1403,8 @@ export const ChatComposer = memo( |
| 1439 | } | 1403 | } |
| 1440 | return; | 1404 | return; |
| 1441 | } | 1405 | } |
| 1442 | onProviderModelSelect(item.provider, item.model); | ||
| 1443 | const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { | ||
| 1444 | expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), | ||
| 1445 | }); | ||
| 1446 | if (applied) { | ||
| 1447 | setComposerHighlightedItemId(null); | ||
| 1448 | } | ||
| 1449 | }, | 1406 | }, |
| 1450 | [ | 1407 | [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], |
| 1451 | applyPromptReplacement, | ||
| 1452 | handleInteractionModeChange, | ||
| 1453 | onProviderModelSelect, | ||
| 1454 | resolveActiveComposerTrigger, | ||
| 1455 | ], | ||
| 1456 | ); | 1408 | ); |
| 1457 | 1409 | ||
| 1458 | const onComposerMenuItemHighlighted = useCallback( | 1410 | const onComposerMenuItemHighlighted = useCallback( |
| ... | @@ -1633,6 +1585,13 @@ export const ChatComposer = memo( | ... | @@ -1633,6 +1585,13 @@ export const ChatComposer = memo( |
| 1633 | focusAt: (cursor: number) => { | 1585 | focusAt: (cursor: number) => { |
| 1634 | composerEditorRef.current?.focusAt(cursor); | 1586 | composerEditorRef.current?.focusAt(cursor); |
| 1635 | }, | 1587 | }, |
| 1588 | openModelPicker: () => { | ||
| 1589 | setIsComposerModelPickerOpen(true); | ||
| 1590 | }, | ||
| 1591 | toggleModelPicker: () => { | ||
| 1592 | setIsComposerModelPickerOpen((open) => !open); | ||
| 1593 | }, | ||
| 1594 | isModelPickerOpen: () => isComposerModelPickerOpen, | ||
| 1636 | readSnapshot: () => { | 1595 | readSnapshot: () => { |
| 1637 | return readComposerSnapshot(); | 1596 | return readComposerSnapshot(); |
| 1638 | }, | 1597 | }, |
| ... | @@ -1710,6 +1669,7 @@ export const ChatComposer = memo( | ... | @@ -1710,6 +1669,7 @@ export const ChatComposer = memo( |
| 1710 | promptRef, | 1669 | promptRef, |
| 1711 | composerImagesRef, | 1670 | composerImagesRef, |
| 1712 | composerTerminalContextsRef, | 1671 | composerTerminalContextsRef, |
| 1672 | isComposerModelPickerOpen, | ||
| 1713 | readComposerSnapshot, | 1673 | readComposerSnapshot, |
| 1714 | selectedModel, | 1674 | selectedModel, |
| 1715 | selectedModelOptionsForDispatch, | 1675 | selectedModelOptionsForDispatch, |
| ... | @@ -1925,13 +1885,19 @@ export const ChatComposer = memo( | ... | @@ -1925,13 +1885,19 @@ export const ChatComposer = memo( |
| 1925 | model={selectedModelForPickerWithCustomFallback} | 1885 | model={selectedModelForPickerWithCustomFallback} |
| 1926 | lockedProvider={lockedProvider} | 1886 | lockedProvider={lockedProvider} |
| 1927 | providers={providerStatuses} | 1887 | providers={providerStatuses} |
| 1888 | keybindings={keybindings} | ||
| 1928 | modelOptionsByProvider={modelOptionsByProvider} | 1889 | modelOptionsByProvider={modelOptionsByProvider} |
| 1890 | terminalOpen={terminalOpen} | ||
| 1891 | open={isComposerModelPickerOpen} | ||
| 1929 | {...(composerProviderState.modelPickerIconClassName | 1892 | {...(composerProviderState.modelPickerIconClassName |
| 1930 | ? { | 1893 | ? { |
| 1931 | activeProviderIconClassName: | 1894 | activeProviderIconClassName: |
| 1932 | composerProviderState.modelPickerIconClassName, | 1895 | composerProviderState.modelPickerIconClassName, |
| 1933 | } | 1896 | } |
| 1934 | : {})} | 1897 | : {})} |
| 1898 | onOpenChange={(open) => { | ||
| 1899 | setIsComposerModelPickerOpen(open); | ||
| 1900 | }} | ||
| 1935 | onProviderModelChange={onProviderModelSelect} | 1901 | onProviderModelChange={onProviderModelSelect} |
| 1936 | /> | 1902 | /> |
| 1937 | 1903 |
apps/web/src/components/chat/ComposerCommandMenu.tsx-14| ... | @@ -10,7 +10,6 @@ import { memo, useLayoutEffect, useMemo, useRef } from "react"; | ... | @@ -10,7 +10,6 @@ import { memo, useLayoutEffect, useMemo, useRef } from "react"; |
| 10 | import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; | 10 | import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; |
| 11 | import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; | 11 | import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; |
| 12 | import { cn } from "~/lib/utils"; | 12 | import { cn } from "~/lib/utils"; |
| 13 | import { Badge } from "../ui/badge"; | ||
| 14 | import { | 13 | import { |
| 15 | Command, | 14 | Command, |
| 16 | CommandGroup, | 15 | CommandGroup, |
| ... | @@ -45,14 +44,6 @@ export type ComposerCommandItem = | ... | @@ -45,14 +44,6 @@ export type ComposerCommandItem = |
| 45 | label: string; | 44 | label: string; |
| 46 | description: string; | 45 | description: string; |
| 47 | } | 46 | } |
| 48 | | { | ||
| 49 | id: string; | ||
| 50 | type: "model"; | ||
| 51 | provider: ProviderKind; | ||
| 52 | model: string; | ||
| 53 | label: string; | ||
| 54 | description: string; | ||
| 55 | } | ||
| 56 | | { | 47 | | { |
| 57 | id: string; | 48 | id: string; |
| 58 | type: "skill"; | 49 | type: "skill"; |
| ... | @@ -255,11 +246,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { | ... | @@ -255,11 +246,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { |
| 255 | <SkillGlyph className="size-3.5" /> | 246 | <SkillGlyph className="size-3.5" /> |
| 256 | </span> | 247 | </span> |
| 257 | ) : null} | 248 | ) : null} |
| 258 | {props.item.type === "model" ? ( | ||
| 259 | <Badge variant="outline" className="px-1.5 py-0 text-[10px]"> | ||
| 260 | model | ||
| 261 | </Badge> | ||
| 262 | ) : null} | ||
| 263 | <span className="flex min-w-0 flex-1 items-center gap-2"> | 249 | <span className="flex min-w-0 flex-1 items-center gap-2"> |
| 264 | <span className="shrink-0">{props.item.label}</span> | 250 | <span className="shrink-0">{props.item.label}</span> |
| 265 | <span className="min-w-0 flex-1 truncate text-muted-foreground/70 text-xs"> | 251 | <span className="min-w-0 flex-1 truncate text-muted-foreground/70 text-xs"> |
apps/web/src/components/chat/ModelListRow.tsx created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import { type ProviderKind } from "@t3tools/contracts"; | ||
| 2 | import { memo } from "react"; | ||
| 3 | import { StarIcon } from "lucide-react"; | ||
| 4 | import { | ||
| 5 | getDisplayModelName, | ||
| 6 | getProviderLabel, | ||
| 7 | getTriggerDisplayModelLabel, | ||
| 8 | type ModelEsque, | ||
| 9 | PROVIDER_ICON_BY_PROVIDER, | ||
| 10 | } from "./providerIconUtils"; | ||
| 11 | import { ComboboxItem } from "../ui/combobox"; | ||
| 12 | import { Kbd } from "../ui/kbd"; | ||
| 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 14 | import { cn } from "~/lib/utils"; | ||
| 15 | |||
| 16 | export const ModelListRow = memo(function ModelListRow(props: { | ||
| 17 | index: number; | ||
| 18 | model: ModelEsque; | ||
| 19 | provider: ProviderKind; | ||
| 20 | isFavorite: boolean; | ||
| 21 | showProvider: boolean; | ||
| 22 | preferShortName?: boolean; | ||
| 23 | useTriggerLabel?: boolean; | ||
| 24 | showNewBadge?: boolean; | ||
| 25 | jumpLabel?: string | null; | ||
| 26 | onToggleFavorite: () => void; | ||
| 27 | }) { | ||
| 28 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; | ||
| 29 | |||
| 30 | return ( | ||
| 31 | <ComboboxItem | ||
| 32 | hideIndicator | ||
| 33 | index={props.index} | ||
| 34 | value={`${props.provider}:${props.model.slug}`} | ||
| 35 | contentClassName="flex w-full items-start gap-2" | ||
| 36 | className={cn( | ||
| 37 | "w-full cursor-pointer rounded px-3 py-2 transition-colors group", | ||
| 38 | "data-highlighted:bg-muted data-selected:bg-accent data-selected:text-foreground", | ||
| 39 | )} | ||
| 40 | > | ||
| 41 | <Tooltip> | ||
| 42 | <TooltipTrigger | ||
| 43 | render={ | ||
| 44 | <button | ||
| 45 | className="mt-0.5 shrink-0 cursor-pointer opacity-40 transition-opacity group-hover:opacity-100" | ||
| 46 | onClick={(event) => { | ||
| 47 | event.stopPropagation(); | ||
| 48 | props.onToggleFavorite(); | ||
| 49 | }} | ||
| 50 | onKeyDown={(event) => { | ||
| 51 | event.stopPropagation(); | ||
| 52 | }} | ||
| 53 | type="button" | ||
| 54 | aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} | ||
| 55 | > | ||
| 56 | <StarIcon | ||
| 57 | className={cn("size-4", props.isFavorite && "fill-current text-yellow-500")} | ||
| 58 | /> | ||
| 59 | </button> | ||
| 60 | } | ||
| 61 | /> | ||
| 62 | <TooltipPopup side="top" align="center"> | ||
| 63 | {props.isFavorite ? "Remove from favorites" : "Add to favorites"} | ||
| 64 | </TooltipPopup> | ||
| 65 | </Tooltip> | ||
| 66 | |||
| 67 | <div className="min-w-0 flex-1 text-left"> | ||
| 68 | <div className="flex items-center justify-between gap-2 min-w-0"> | ||
| 69 | <div className="text-xs font-medium leading-snug flex items-center gap-2 min-w-0"> | ||
| 70 | <span className="truncate"> | ||
| 71 | {props.useTriggerLabel | ||
| 72 | ? getTriggerDisplayModelLabel(props.model) | ||
| 73 | : getDisplayModelName( | ||
| 74 | props.model, | ||
| 75 | props.preferShortName ? { preferShortName: true } : undefined, | ||
| 76 | )} | ||
| 77 | </span> | ||
| 78 | {props.showNewBadge ? ( | ||
| 79 | <span | ||
| 80 | className="shrink-0 rounded border border-amber-500/35 bg-amber-500/15 px-0.5 py-px text-[10px] font-bold uppercase leading-none tracking-wide text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/12 dark:text-amber-200" | ||
| 81 | aria-label="New model" | ||
| 82 | > | ||
| 83 | New | ||
| 84 | </span> | ||
| 85 | ) : null} | ||
| 86 | </div> | ||
| 87 | {props.jumpLabel ? ( | ||
| 88 | <Kbd className="h-4 min-w-0 shrink-0 rounded-sm px-1.5 text-[10px]"> | ||
| 89 | {props.jumpLabel} | ||
| 90 | </Kbd> | ||
| 91 | ) : null} | ||
| 92 | </div> | ||
| 93 | {props.showProvider && ( | ||
| 94 | <div className="flex items-center gap-1 mt-0.5"> | ||
| 95 | <ProviderIcon className="size-3 shrink-0" /> | ||
| 96 | <span className="text-xs font-normal leading-snug text-muted-foreground/70 truncate"> | ||
| 97 | {getProviderLabel(props.provider, props.model)} | ||
| 98 | </span> | ||
| 99 | </div> | ||
| 100 | )} | ||
| 101 | </div> | ||
| 102 | </ComboboxItem> | ||
| 103 | ); | ||
| 104 | }); | ||
apps/web/src/components/chat/ModelPickerContent.tsx created+519| ... | @@ -0,0 +1,519 @@ | ||
| 1 | import { | ||
| 2 | type ProviderKind, | ||
| 3 | PROVIDER_DISPLAY_NAMES, | ||
| 4 | type ResolvedKeybindingsConfig, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { resolveSelectableModel } from "@t3tools/shared/model"; | ||
| 8 | import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; | ||
| 9 | import { SearchIcon } from "lucide-react"; | ||
| 10 | import { ModelListRow } from "./ModelListRow"; | ||
| 11 | import { ModelPickerSidebar } from "./ModelPickerSidebar"; | ||
| 12 | import { isModelPickerNewModel } from "./modelPickerModelHighlights"; | ||
| 13 | import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; | ||
| 14 | import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; | ||
| 15 | import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; | ||
| 16 | import { | ||
| 17 | modelPickerJumpCommandForIndex, | ||
| 18 | modelPickerJumpIndexFromCommand, | ||
| 19 | resolveShortcutCommand, | ||
| 20 | shortcutLabelForCommand, | ||
| 21 | } from "../../keybindings"; | ||
| 22 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; | ||
| 23 | import { cn } from "~/lib/utils"; | ||
| 24 | import { TooltipProvider } from "../ui/tooltip"; | ||
| 25 | |||
| 26 | type ModelPickerItem = { | ||
| 27 | slug: string; | ||
| 28 | name: string; | ||
| 29 | shortName?: string; | ||
| 30 | subProvider?: string; | ||
| 31 | provider: ProviderKind; | ||
| 32 | }; | ||
| 33 | |||
| 34 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); | ||
| 35 | |||
| 36 | export const ModelPickerContent = memo(function ModelPickerContent(props: { | ||
| 37 | provider: ProviderKind; | ||
| 38 | model: string; | ||
| 39 | lockedProvider: ProviderKind | null; | ||
| 40 | providers?: ReadonlyArray<ServerProvider>; | ||
| 41 | keybindings?: ResolvedKeybindingsConfig; | ||
| 42 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<ModelEsque>>; | ||
| 43 | terminalOpen: boolean; | ||
| 44 | onRequestClose?: () => void; | ||
| 45 | onProviderModelChange: (provider: ProviderKind, model: string) => void; | ||
| 46 | }) { | ||
| 47 | const { keybindings: providedKeybindings, modelOptionsByProvider, onProviderModelChange } = props; | ||
| 48 | const [searchQuery, setSearchQuery] = useState(""); | ||
| 49 | const searchInputRef = useRef<HTMLInputElement>(null); | ||
| 50 | const listRegionRef = useRef<HTMLDivElement>(null); | ||
| 51 | const highlightedModelKeyRef = useRef<string | null>(null); | ||
| 52 | const favorites = useSettings((s) => s.favorites ?? []); | ||
| 53 | const [selectedProvider, setSelectedProvider] = useState<ProviderKind | "favorites">(() => { | ||
| 54 | if (props.lockedProvider !== null) { | ||
| 55 | return props.lockedProvider; | ||
| 56 | } | ||
| 57 | return favorites.length > 0 ? "favorites" : props.provider; | ||
| 58 | }); | ||
| 59 | const keybindings = useMemo<ResolvedKeybindingsConfig>( | ||
| 60 | () => providedKeybindings ?? [], | ||
| 61 | [providedKeybindings], | ||
| 62 | ); | ||
| 63 | const { updateSettings } = useUpdateSettings(); | ||
| 64 | |||
| 65 | const focusSearchInput = useCallback(() => { | ||
| 66 | searchInputRef.current?.focus({ preventScroll: true }); | ||
| 67 | }, []); | ||
| 68 | |||
| 69 | const handleSelectProvider = useCallback( | ||
| 70 | (provider: ProviderKind | "favorites") => { | ||
| 71 | setSelectedProvider(provider); | ||
| 72 | window.requestAnimationFrame(() => { | ||
| 73 | focusSearchInput(); | ||
| 74 | }); | ||
| 75 | }, | ||
| 76 | [focusSearchInput], | ||
| 77 | ); | ||
| 78 | |||
| 79 | useLayoutEffect(() => { | ||
| 80 | focusSearchInput(); | ||
| 81 | const frame = window.requestAnimationFrame(() => { | ||
| 82 | focusSearchInput(); | ||
| 83 | }); | ||
| 84 | const timeout = window.setTimeout(() => { | ||
| 85 | focusSearchInput(); | ||
| 86 | }, 0); | ||
| 87 | return () => { | ||
| 88 | window.cancelAnimationFrame(frame); | ||
| 89 | window.clearTimeout(timeout); | ||
| 90 | }; | ||
| 91 | }, [focusSearchInput]); | ||
| 92 | |||
| 93 | // Create a Set for efficient lookup | ||
| 94 | const favoritesSet = useMemo(() => { | ||
| 95 | return new Set(favorites.map((fav) => `${fav.provider}:${fav.model}`)); | ||
| 96 | }, [favorites]); | ||
| 97 | const favoriteOrder = useMemo(() => { | ||
| 98 | return new Map( | ||
| 99 | favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), | ||
| 100 | ); | ||
| 101 | }, [favorites]); | ||
| 102 | |||
| 103 | const readyProviderSet = useMemo(() => { | ||
| 104 | if (!props.providers || props.providers.length === 0) { | ||
| 105 | return null; | ||
| 106 | } | ||
| 107 | return new Set( | ||
| 108 | props.providers | ||
| 109 | .filter((provider) => provider.status === "ready") | ||
| 110 | .map((provider) => provider.provider), | ||
| 111 | ); | ||
| 112 | }, [props.providers]); | ||
| 113 | |||
| 114 | // Flatten models into a searchable array | ||
| 115 | const flatModels = useMemo(() => { | ||
| 116 | return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { | ||
| 117 | if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { | ||
| 118 | return []; | ||
| 119 | } | ||
| 120 | return models.map((m) => ({ | ||
| 121 | slug: m.slug, | ||
| 122 | name: m.name, | ||
| 123 | ...(m.shortName ? { shortName: m.shortName } : {}), | ||
| 124 | ...(m.subProvider ? { subProvider: m.subProvider } : {}), | ||
| 125 | provider: providerKind as ProviderKind, | ||
| 126 | })) satisfies Array<ModelPickerItem>; | ||
| 127 | }); | ||
| 128 | }, [props.modelOptionsByProvider, readyProviderSet]); | ||
| 129 | |||
| 130 | // Filter models based on search query and selected provider | ||
| 131 | const filteredModels = useMemo(() => { | ||
| 132 | let result = flatModels; | ||
| 133 | |||
| 134 | // Apply tokenized fuzzy search across the combined provider/model search fields. | ||
| 135 | if (searchQuery.trim()) { | ||
| 136 | const rankedMatches = result | ||
| 137 | .map((model) => ({ | ||
| 138 | model, | ||
| 139 | score: scoreModelPickerSearch( | ||
| 140 | { | ||
| 141 | ...model, | ||
| 142 | isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), | ||
| 143 | }, | ||
| 144 | searchQuery, | ||
| 145 | ), | ||
| 146 | isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), | ||
| 147 | tieBreaker: buildModelPickerSearchText(model), | ||
| 148 | })) | ||
| 149 | .filter( | ||
| 150 | ( | ||
| 151 | rankedModel, | ||
| 152 | ): rankedModel is { | ||
| 153 | model: ModelPickerItem; | ||
| 154 | score: number; | ||
| 155 | isFavorite: boolean; | ||
| 156 | tieBreaker: string; | ||
| 157 | } => rankedModel.score !== null, | ||
| 158 | ); | ||
| 159 | |||
| 160 | // When searching, we only respect locked provider, ignoring sidebar selection | ||
| 161 | if (props.lockedProvider !== null) { | ||
| 162 | return rankedMatches | ||
| 163 | .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider) | ||
| 164 | .toSorted((a, b) => { | ||
| 165 | const scoreDelta = a.score - b.score; | ||
| 166 | if (scoreDelta !== 0) { | ||
| 167 | return scoreDelta; | ||
| 168 | } | ||
| 169 | if (a.isFavorite !== b.isFavorite) { | ||
| 170 | return a.isFavorite ? -1 : 1; | ||
| 171 | } | ||
| 172 | return a.tieBreaker.localeCompare(b.tieBreaker); | ||
| 173 | }) | ||
| 174 | .map((rankedModel) => rankedModel.model); | ||
| 175 | } | ||
| 176 | |||
| 177 | return rankedMatches | ||
| 178 | .toSorted((a, b) => { | ||
| 179 | const scoreDelta = a.score - b.score; | ||
| 180 | if (scoreDelta !== 0) { | ||
| 181 | return scoreDelta; | ||
| 182 | } | ||
| 183 | if (a.isFavorite !== b.isFavorite) { | ||
| 184 | return a.isFavorite ? -1 : 1; | ||
| 185 | } | ||
| 186 | return a.tieBreaker.localeCompare(b.tieBreaker); | ||
| 187 | }) | ||
| 188 | .map((rankedModel) => rankedModel.model); | ||
| 189 | } | ||
| 190 | |||
| 191 | // Locked provider mode always shows that provider's models, with favorites first. | ||
| 192 | if (props.lockedProvider !== null) { | ||
| 193 | result = result.filter((m) => m.provider === props.lockedProvider); | ||
| 194 | } else if (selectedProvider === "favorites") { | ||
| 195 | result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); | ||
| 196 | } else { | ||
| 197 | result = result.filter((m) => m.provider === selectedProvider); | ||
| 198 | } | ||
| 199 | |||
| 200 | return result.toSorted((a, b) => { | ||
| 201 | const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`); | ||
| 202 | const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`); | ||
| 203 | |||
| 204 | if (aOrder !== undefined && bOrder !== undefined) { | ||
| 205 | return aOrder - bOrder; | ||
| 206 | } | ||
| 207 | if (aOrder !== undefined) { | ||
| 208 | return -1; | ||
| 209 | } | ||
| 210 | if (bOrder !== undefined) { | ||
| 211 | return 1; | ||
| 212 | } | ||
| 213 | return 0; | ||
| 214 | }); | ||
| 215 | }, [ | ||
| 216 | favoriteOrder, | ||
| 217 | favoritesSet, | ||
| 218 | flatModels, | ||
| 219 | props.lockedProvider, | ||
| 220 | searchQuery, | ||
| 221 | selectedProvider, | ||
| 222 | ]); | ||
| 223 | |||
| 224 | const handleModelSelect = useCallback( | ||
| 225 | (modelSlug: string, provider: ProviderKind) => { | ||
| 226 | const resolvedModel = resolveSelectableModel( | ||
| 227 | provider, | ||
| 228 | modelSlug, | ||
| 229 | modelOptionsByProvider[provider], | ||
| 230 | ); | ||
| 231 | if (resolvedModel) { | ||
| 232 | onProviderModelChange(provider, resolvedModel); | ||
| 233 | } | ||
| 234 | }, | ||
| 235 | [modelOptionsByProvider, onProviderModelChange], | ||
| 236 | ); | ||
| 237 | |||
| 238 | const toggleFavorite = useCallback( | ||
| 239 | (provider: ProviderKind, model: string) => { | ||
| 240 | const newFavorites = [...favorites]; | ||
| 241 | const index = newFavorites.findIndex((f) => f.provider === provider && f.model === model); | ||
| 242 | if (index >= 0) { | ||
| 243 | newFavorites.splice(index, 1); | ||
| 244 | } else { | ||
| 245 | newFavorites.push({ provider, model }); | ||
| 246 | } | ||
| 247 | updateSettings({ favorites: newFavorites }); | ||
| 248 | }, | ||
| 249 | [favorites, updateSettings], | ||
| 250 | ); | ||
| 251 | |||
| 252 | const isLocked = props.lockedProvider !== null; | ||
| 253 | const isSearching = searchQuery.trim().length > 0; | ||
| 254 | const showSidebar = !isLocked && !isSearching; | ||
| 255 | const LockedProviderIcon = | ||
| 256 | isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; | ||
| 257 | const modelJumpCommandByKey = useMemo(() => { | ||
| 258 | const mapping = new Map< | ||
| 259 | string, | ||
| 260 | NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>> | ||
| 261 | >(); | ||
| 262 | for (const [visibleModelIndex, model] of filteredModels.entries()) { | ||
| 263 | const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex); | ||
| 264 | if (!jumpCommand) { | ||
| 265 | return mapping; | ||
| 266 | } | ||
| 267 | mapping.set(`${model.provider}:${model.slug}`, jumpCommand); | ||
| 268 | } | ||
| 269 | return mapping; | ||
| 270 | }, [filteredModels]); | ||
| 271 | const modelJumpModelKeys = useMemo( | ||
| 272 | () => [...modelJumpCommandByKey.keys()], | ||
| 273 | [modelJumpCommandByKey], | ||
| 274 | ); | ||
| 275 | const allModelKeys = useMemo( | ||
| 276 | (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`), | ||
| 277 | [flatModels], | ||
| 278 | ); | ||
| 279 | const filteredModelKeys = useMemo( | ||
| 280 | (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`), | ||
| 281 | [filteredModels], | ||
| 282 | ); | ||
| 283 | const filteredModelByKey = useMemo( | ||
| 284 | (): ReadonlyMap<string, ModelPickerItem> => | ||
| 285 | new Map(filteredModels.map((model) => [`${model.provider}:${model.slug}`, model] as const)), | ||
| 286 | [filteredModels], | ||
| 287 | ); | ||
| 288 | const modelJumpShortcutContext = useMemo( | ||
| 289 | () => | ||
| 290 | ({ | ||
| 291 | terminalFocus: false, | ||
| 292 | terminalOpen: props.terminalOpen, | ||
| 293 | modelPickerOpen: true, | ||
| 294 | }) as const, | ||
| 295 | [props.terminalOpen], | ||
| 296 | ); | ||
| 297 | const modelJumpLabelByKey = useMemo((): ReadonlyMap<string, string> => { | ||
| 298 | if (modelJumpCommandByKey.size === 0) { | ||
| 299 | return EMPTY_MODEL_JUMP_LABELS; | ||
| 300 | } | ||
| 301 | const shortcutLabelOptions = { | ||
| 302 | platform: navigator.platform, | ||
| 303 | context: modelJumpShortcutContext, | ||
| 304 | }; | ||
| 305 | const mapping = new Map<string, string>(); | ||
| 306 | for (const [modelKey, command] of modelJumpCommandByKey) { | ||
| 307 | const label = shortcutLabelForCommand(keybindings, command, shortcutLabelOptions); | ||
| 308 | if (label) { | ||
| 309 | mapping.set(modelKey, label); | ||
| 310 | } | ||
| 311 | } | ||
| 312 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; | ||
| 313 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); | ||
| 314 | |||
| 315 | useEffect(() => { | ||
| 316 | const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | ||
| 317 | if (event.defaultPrevented || event.repeat) { | ||
| 318 | return; | ||
| 319 | } | ||
| 320 | |||
| 321 | const command = resolveShortcutCommand(event, keybindings, { | ||
| 322 | platform: navigator.platform, | ||
| 323 | context: modelJumpShortcutContext, | ||
| 324 | }); | ||
| 325 | const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); | ||
| 326 | if (jumpIndex === null) { | ||
| 327 | return; | ||
| 328 | } | ||
| 329 | |||
| 330 | const targetModelKey = modelJumpModelKeys[jumpIndex]; | ||
| 331 | if (!targetModelKey) { | ||
| 332 | return; | ||
| 333 | } | ||
| 334 | const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string]; | ||
| 335 | event.preventDefault(); | ||
| 336 | event.stopPropagation(); | ||
| 337 | handleModelSelect(slug, provider); | ||
| 338 | }; | ||
| 339 | |||
| 340 | window.addEventListener("keydown", onWindowKeyDown, true); | ||
| 341 | |||
| 342 | return () => { | ||
| 343 | window.removeEventListener("keydown", onWindowKeyDown, true); | ||
| 344 | }; | ||
| 345 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); | ||
| 346 | |||
| 347 | useLayoutEffect(() => { | ||
| 348 | const listRegion = listRegionRef.current; | ||
| 349 | if (!listRegion) { | ||
| 350 | return; | ||
| 351 | } | ||
| 352 | |||
| 353 | let cancelled = false; | ||
| 354 | let frame = 0; | ||
| 355 | let nestedFrame = 0; | ||
| 356 | let timeout = 0; | ||
| 357 | |||
| 358 | const measureScrollArea = () => { | ||
| 359 | if (cancelled) { | ||
| 360 | return; | ||
| 361 | } | ||
| 362 | const viewport = listRegion.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]'); | ||
| 363 | if (!viewport || viewport.scrollHeight <= viewport.clientHeight) { | ||
| 364 | return; | ||
| 365 | } | ||
| 366 | const originalScrollTop = viewport.scrollTop; | ||
| 367 | const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; | ||
| 368 | if (maxScrollTop <= 0) { | ||
| 369 | return; | ||
| 370 | } | ||
| 371 | viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop); | ||
| 372 | viewport.scrollTop = originalScrollTop; | ||
| 373 | }; | ||
| 374 | |||
| 375 | queueMicrotask(measureScrollArea); | ||
| 376 | frame = window.requestAnimationFrame(() => { | ||
| 377 | measureScrollArea(); | ||
| 378 | nestedFrame = window.requestAnimationFrame(measureScrollArea); | ||
| 379 | }); | ||
| 380 | timeout = window.setTimeout(measureScrollArea, 0); | ||
| 381 | |||
| 382 | return () => { | ||
| 383 | cancelled = true; | ||
| 384 | window.cancelAnimationFrame(frame); | ||
| 385 | window.cancelAnimationFrame(nestedFrame); | ||
| 386 | window.clearTimeout(timeout); | ||
| 387 | }; | ||
| 388 | }, [filteredModelKeys]); | ||
| 389 | |||
| 390 | return ( | ||
| 391 | <TooltipProvider delay={0}> | ||
| 392 | <div | ||
| 393 | className={cn( | ||
| 394 | "relative flex h-screen max-h-96 w-screen max-w-100 overflow-hidden rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", | ||
| 395 | isLocked ? "flex-col" : "flex-row", | ||
| 396 | )} | ||
| 397 | > | ||
| 398 | {/* Locked provider header (only shown in locked mode) */} | ||
| 399 | {isLocked && LockedProviderIcon && props.lockedProvider && ( | ||
| 400 | <div className="flex items-center gap-2 px-4 py-3 border-b"> | ||
| 401 | <LockedProviderIcon className="size-5 shrink-0" /> | ||
| 402 | <span className="font-medium text-sm"> | ||
| 403 | {PROVIDER_DISPLAY_NAMES[props.lockedProvider]} | ||
| 404 | </span> | ||
| 405 | </div> | ||
| 406 | )} | ||
| 407 | |||
| 408 | {/* Sidebar (only in unlocked mode) */} | ||
| 409 | {showSidebar && ( | ||
| 410 | <ModelPickerSidebar | ||
| 411 | selectedProvider={selectedProvider} | ||
| 412 | onSelectProvider={handleSelectProvider} | ||
| 413 | {...(props.providers && { providers: props.providers })} | ||
| 414 | /> | ||
| 415 | )} | ||
| 416 | |||
| 417 | {/* Main content area */} | ||
| 418 | <Combobox | ||
| 419 | inline | ||
| 420 | items={allModelKeys} | ||
| 421 | filteredItems={filteredModelKeys} | ||
| 422 | filter={null} | ||
| 423 | autoHighlight | ||
| 424 | open | ||
| 425 | value={`${props.provider}:${props.model}`} | ||
| 426 | onItemHighlighted={(modelKey) => { | ||
| 427 | highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; | ||
| 428 | }} | ||
| 429 | onValueChange={(modelKey) => { | ||
| 430 | if (typeof modelKey !== "string") { | ||
| 431 | return; | ||
| 432 | } | ||
| 433 | const [provider, slug] = modelKey.split(":") as [ProviderKind, string]; | ||
| 434 | handleModelSelect(slug, provider); | ||
| 435 | }} | ||
| 436 | > | ||
| 437 | <div | ||
| 438 | className={cn( | ||
| 439 | "flex min-h-0 flex-1 flex-col overflow-hidden", | ||
| 440 | isLocked ? "min-w-0" : showSidebar && "border-l", | ||
| 441 | )} | ||
| 442 | > | ||
| 443 | {/* Search bar */} | ||
| 444 | <div className="border-b px-3 py-2"> | ||
| 445 | <ComboboxInput | ||
| 446 | ref={searchInputRef} | ||
| 447 | className="[&_input]:font-sans rounded-md" | ||
| 448 | inputClassName="border-0 shadow-none ring-0 focus-visible:ring-0" | ||
| 449 | placeholder="Search models..." | ||
| 450 | showTrigger={false} | ||
| 451 | startAddon={<SearchIcon className="size-4 shrink-0 text-muted-foreground/50" />} | ||
| 452 | value={searchQuery} | ||
| 453 | onChange={(e) => setSearchQuery(e.target.value)} | ||
| 454 | onKeyDown={(e) => { | ||
| 455 | if (e.key === "Escape") { | ||
| 456 | e.preventDefault(); | ||
| 457 | e.stopPropagation(); | ||
| 458 | props.onRequestClose?.(); | ||
| 459 | return; | ||
| 460 | } | ||
| 461 | if (e.key === "Enter" && highlightedModelKeyRef.current) { | ||
| 462 | ( | ||
| 463 | e as typeof e & { preventBaseUIHandler?: () => void } | ||
| 464 | ).preventBaseUIHandler?.(); | ||
| 465 | e.preventDefault(); | ||
| 466 | e.stopPropagation(); | ||
| 467 | const [provider, slug] = highlightedModelKeyRef.current.split(":") as [ | ||
| 468 | ProviderKind, | ||
| 469 | string, | ||
| 470 | ]; | ||
| 471 | handleModelSelect(slug, provider); | ||
| 472 | return; | ||
| 473 | } | ||
| 474 | e.stopPropagation(); | ||
| 475 | }} | ||
| 476 | onMouseDown={(e) => e.stopPropagation()} | ||
| 477 | onTouchStart={(e) => e.stopPropagation()} | ||
| 478 | size="sm" | ||
| 479 | /> | ||
| 480 | </div> | ||
| 481 | |||
| 482 | {/* Model list */} | ||
| 483 | <div | ||
| 484 | ref={listRegionRef} | ||
| 485 | className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40" | ||
| 486 | > | ||
| 487 | <ComboboxList className="model-picker-list size-full divide-y px-2 py-1"> | ||
| 488 | {filteredModelKeys.map((modelKey, index) => { | ||
| 489 | const model = filteredModelByKey.get(modelKey); | ||
| 490 | if (!model) { | ||
| 491 | return null; | ||
| 492 | } | ||
| 493 | return ( | ||
| 494 | <ModelListRow | ||
| 495 | key={modelKey} | ||
| 496 | index={index} | ||
| 497 | model={model} | ||
| 498 | provider={model.provider} | ||
| 499 | isFavorite={favoritesSet.has(modelKey)} | ||
| 500 | showProvider={!isLocked} | ||
| 501 | preferShortName={!isLocked} | ||
| 502 | useTriggerLabel={isLocked} | ||
| 503 | showNewBadge={isModelPickerNewModel(model.provider, model.slug)} | ||
| 504 | jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} | ||
| 505 | onToggleFavorite={() => toggleFavorite(model.provider, model.slug)} | ||
| 506 | /> | ||
| 507 | ); | ||
| 508 | })} | ||
| 509 | </ComboboxList> | ||
| 510 | </div> | ||
| 511 | <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug"> | ||
| 512 | No models found | ||
| 513 | </ComboboxEmpty> | ||
| 514 | </div> | ||
| 515 | </Combobox> | ||
| 516 | </div> | ||
| 517 | </TooltipProvider> | ||
| 518 | ); | ||
| 519 | }); | ||
apps/web/src/components/chat/ModelPickerSidebar.tsx created+210| ... | @@ -0,0 +1,210 @@ | ||
| 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { memo } from "react"; | ||
| 3 | import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; | ||
| 4 | import { Gemini, GithubCopilotIcon } from "../Icons"; | ||
| 5 | import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; | ||
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 7 | import { cn } from "~/lib/utils"; | ||
| 8 | import { getProviderSnapshot } from "../../providerModels"; | ||
| 9 | |||
| 10 | function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string { | ||
| 11 | if (!live) { | ||
| 12 | return `${label} — waiting for provider status…`; | ||
| 13 | } | ||
| 14 | if (live.status === "ready") { | ||
| 15 | return label; | ||
| 16 | } | ||
| 17 | const kind = | ||
| 18 | live.status === "error" | ||
| 19 | ? "Unavailable" | ||
| 20 | : live.status === "warning" | ||
| 21 | ? "Limited" | ||
| 22 | : live.status === "disabled" | ||
| 23 | ? "Disabled in settings" | ||
| 24 | : "Not ready"; | ||
| 25 | const msg = live.message?.trim(); | ||
| 26 | return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; | ||
| 27 | } | ||
| 28 | |||
| 29 | const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; | ||
| 30 | const SELECTED_INDICATOR_CLASS = | ||
| 31 | "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"; | ||
| 32 | const BADGE_BASE_CLASS = | ||
| 33 | "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 "; | ||
| 34 | const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; | ||
| 35 | const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; | ||
| 36 | |||
| 37 | /** Opens toward the rail so the list stays readable (not over the model names). */ | ||
| 38 | const PICKER_TOOLTIP_SIDE = "left" as const; | ||
| 39 | const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; | ||
| 40 | |||
| 41 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { | ||
| 42 | selectedProvider: ProviderKind | "favorites"; | ||
| 43 | onSelectProvider: (provider: ProviderKind | "favorites") => void; | ||
| 44 | providers?: ReadonlyArray<ServerProvider>; | ||
| 45 | }) { | ||
| 46 | const handleProviderClick = (provider: ProviderKind | "favorites") => { | ||
| 47 | props.onSelectProvider(provider); | ||
| 48 | }; | ||
| 49 | |||
| 50 | return ( | ||
| 51 | <div className="flex flex-col w-12 border-r bg-muted/30 p-1 overflow-y-auto gap-1"> | ||
| 52 | {/* Favorites section */} | ||
| 53 | <div className="pb-1 mb-1 border-b"> | ||
| 54 | <div className="relative w-full"> | ||
| 55 | {props.selectedProvider === "favorites" && <div className={SELECTED_INDICATOR_CLASS} />} | ||
| 56 | <Tooltip> | ||
| 57 | <TooltipTrigger | ||
| 58 | render={ | ||
| 59 | <button | ||
| 60 | className={cn( | ||
| 61 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | ||
| 62 | props.selectedProvider === "favorites" && SELECTED_BUTTON_CLASS, | ||
| 63 | )} | ||
| 64 | onClick={() => handleProviderClick("favorites")} | ||
| 65 | type="button" | ||
| 66 | data-model-picker-provider="favorites" | ||
| 67 | aria-label="Favorites" | ||
| 68 | > | ||
| 69 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> | ||
| 70 | </button> | ||
| 71 | } | ||
| 72 | /> | ||
| 73 | <TooltipPopup | ||
| 74 | side={PICKER_TOOLTIP_SIDE} | ||
| 75 | align="center" | ||
| 76 | className={PICKER_TOOLTIP_CLASS} | ||
| 77 | > | ||
| 78 | Favorites | ||
| 79 | </TooltipPopup> | ||
| 80 | </Tooltip> | ||
| 81 | </div> | ||
| 82 | </div> | ||
| 83 | |||
| 84 | {/* Provider buttons */} | ||
| 85 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | ||
| 86 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | ||
| 87 | const liveProvider = props.providers | ||
| 88 | ? getProviderSnapshot(props.providers, option.value) | ||
| 89 | : undefined; | ||
| 90 | |||
| 91 | const isDisabled = !liveProvider || liveProvider.status !== "ready"; | ||
| 92 | const isSelected = props.selectedProvider === option.value; | ||
| 93 | const badge = option.pickerSidebarBadge; | ||
| 94 | |||
| 95 | const providerTooltip = isDisabled | ||
| 96 | ? describeUnavailableProvider(option.label, liveProvider) | ||
| 97 | : badge === "new" | ||
| 98 | ? `${option.label} — New` | ||
| 99 | : option.label; | ||
| 100 | |||
| 101 | const button = ( | ||
| 102 | <button | ||
| 103 | data-model-picker-provider={option.value} | ||
| 104 | className={cn( | ||
| 105 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded transition-colors hover:bg-muted", | ||
| 106 | isSelected && SELECTED_BUTTON_CLASS, | ||
| 107 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", | ||
| 108 | )} | ||
| 109 | onClick={() => !isDisabled && handleProviderClick(option.value)} | ||
| 110 | disabled={isDisabled} | ||
| 111 | type="button" | ||
| 112 | aria-label={ | ||
| 113 | isDisabled | ||
| 114 | ? (providerTooltip ?? option.label) | ||
| 115 | : badge === "new" | ||
| 116 | ? `${option.label}, new` | ||
| 117 | : option.label | ||
| 118 | } | ||
| 119 | > | ||
| 120 | <OptionIcon className="size-5 shrink-0" aria-hidden /> | ||
| 121 | {badge === "new" ? ( | ||
| 122 | <span className={NEW_BADGE_CLASS} aria-hidden> | ||
| 123 | <SparklesIcon className="size-2" /> | ||
| 124 | </span> | ||
| 125 | ) : badge === "soon" ? ( | ||
| 126 | <span className={SOON_BADGE_CLASS} aria-hidden> | ||
| 127 | <Clock3Icon className="size-2" /> | ||
| 128 | </span> | ||
| 129 | ) : null} | ||
| 130 | </button> | ||
| 131 | ); | ||
| 132 | |||
| 133 | const trigger = isDisabled ? ( | ||
| 134 | <span className="relative block w-full">{button}</span> | ||
| 135 | ) : ( | ||
| 136 | button | ||
| 137 | ); | ||
| 138 | |||
| 139 | return ( | ||
| 140 | <div key={option.value} className="relative w-full"> | ||
| 141 | {isSelected && <div className={SELECTED_INDICATOR_CLASS} />} | ||
| 142 | <Tooltip> | ||
| 143 | <TooltipTrigger render={trigger} /> | ||
| 144 | <TooltipPopup | ||
| 145 | side={PICKER_TOOLTIP_SIDE} | ||
| 146 | align="center" | ||
| 147 | className={PICKER_TOOLTIP_CLASS} | ||
| 148 | > | ||
| 149 | {providerTooltip} | ||
| 150 | </TooltipPopup> | ||
| 151 | </Tooltip> | ||
| 152 | </div> | ||
| 153 | ); | ||
| 154 | })} | ||
| 155 | |||
| 156 | {/* Gemini button (coming soon) */} | ||
| 157 | <Tooltip> | ||
| 158 | <TooltipTrigger | ||
| 159 | render={ | ||
| 160 | <span className="relative block w-full"> | ||
| 161 | <button | ||
| 162 | className={cn( | ||
| 163 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | ||
| 164 | )} | ||
| 165 | disabled | ||
| 166 | type="button" | ||
| 167 | data-model-picker-provider="gemini-coming-soon" | ||
| 168 | aria-label="Gemini — coming soon" | ||
| 169 | > | ||
| 170 | <Gemini className="size-5 text-muted-foreground/85" aria-hidden /> | ||
| 171 | <span className={SOON_BADGE_CLASS} aria-hidden> | ||
| 172 | <Clock3Icon className="size-2" /> | ||
| 173 | </span> | ||
| 174 | </button> | ||
| 175 | </span> | ||
| 176 | } | ||
| 177 | /> | ||
| 178 | <TooltipPopup side={PICKER_TOOLTIP_SIDE} align="center" className={PICKER_TOOLTIP_CLASS}> | ||
| 179 | Gemini — Coming soon | ||
| 180 | </TooltipPopup> | ||
| 181 | </Tooltip> | ||
| 182 | {/* Github Copilot button (coming soon) */} | ||
| 183 | <Tooltip> | ||
| 184 | <TooltipTrigger | ||
| 185 | render={ | ||
| 186 | <span className="relative block w-full"> | ||
| 187 | <button | ||
| 188 | className={cn( | ||
| 189 | "relative isolate flex w-full aspect-square items-center justify-center rounded opacity-50 cursor-not-allowed transition-colors hover:bg-transparent", | ||
| 190 | )} | ||
| 191 | disabled | ||
| 192 | type="button" | ||
| 193 | data-model-picker-provider="github-copilot-coming-soon" | ||
| 194 | aria-label="Github Copilot — coming soon" | ||
| 195 | > | ||
| 196 | <GithubCopilotIcon className="size-5 text-muted-foreground/85" aria-hidden /> | ||
| 197 | <span className={SOON_BADGE_CLASS} aria-hidden> | ||
| 198 | <Clock3Icon className="size-2" /> | ||
| 199 | </span> | ||
| 200 | </button> | ||
| 201 | </span> | ||
| 202 | } | ||
| 203 | /> | ||
| 204 | <TooltipPopup side={PICKER_TOOLTIP_SIDE} align="center" className={PICKER_TOOLTIP_CLASS}> | ||
| 205 | Github Copilot — Coming soon | ||
| 206 | </TooltipPopup> | ||
| 207 | </Tooltip> | ||
| 208 | </div> | ||
| 209 | ); | ||
| 210 | }); | ||
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+686-65| ... | @@ -1,11 +1,70 @@ | ... | @@ -1,11 +1,70 @@ |
| 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { page } from "vitest/browser"; | 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { afterEach, describe, expect, it, vi } from "vitest"; | 3 | import { page, userEvent } from "vitest/browser"; |
| 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 4 | import { render } from "vitest-browser-react"; | 5 | import { render } from "vitest-browser-react"; |
| 5 | 6 | ||
| 6 | import { ProviderModelPicker } from "./ProviderModelPicker"; | 7 | import { ProviderModelPicker } from "./ProviderModelPicker"; |
| 7 | import { getCustomModelOptionsByProvider } from "../../modelSelection"; | 8 | import { getCustomModelOptionsByProvider } from "../../modelSelection"; |
| 8 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 9 | import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 10 | import { __resetLocalApiForTests } from "../../localApi"; | ||
| 11 | |||
| 12 | // Mock the environments/runtime module to provide a mock primary environment connection | ||
| 13 | vi.mock("../../environments/runtime", () => { | ||
| 14 | const primaryConnection = { | ||
| 15 | kind: "primary" as const, | ||
| 16 | knownEnvironment: { | ||
| 17 | id: "environment-local", | ||
| 18 | label: "Local environment", | ||
| 19 | source: "manual" as const, | ||
| 20 | environmentId: EnvironmentId.make("environment-local"), | ||
| 21 | target: { | ||
| 22 | httpBaseUrl: "http://localhost:3000", | ||
| 23 | wsBaseUrl: "ws://localhost:3000", | ||
| 24 | }, | ||
| 25 | }, | ||
| 26 | environmentId: EnvironmentId.make("environment-local"), | ||
| 27 | client: { | ||
| 28 | server: { | ||
| 29 | getConfig: vi.fn(), | ||
| 30 | updateSettings: vi.fn(), | ||
| 31 | }, | ||
| 32 | }, | ||
| 33 | ensureBootstrapped: async () => undefined, | ||
| 34 | reconnect: async () => undefined, | ||
| 35 | dispose: async () => undefined, | ||
| 36 | }; | ||
| 37 | |||
| 38 | return { | ||
| 39 | getEnvironmentHttpBaseUrl: () => "http://localhost:3000", | ||
| 40 | getSavedEnvironmentRecord: () => null, | ||
| 41 | getSavedEnvironmentRuntimeState: () => null, | ||
| 42 | hasSavedEnvironmentRegistryHydrated: () => true, | ||
| 43 | listSavedEnvironmentRecords: () => [], | ||
| 44 | resetSavedEnvironmentRegistryStoreForTests: vi.fn(), | ||
| 45 | resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), | ||
| 46 | resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => | ||
| 47 | new URL(path, "http://localhost:3000").toString(), | ||
| 48 | waitForSavedEnvironmentRegistryHydration: async () => undefined, | ||
| 49 | addSavedEnvironment: vi.fn(), | ||
| 50 | disconnectSavedEnvironment: vi.fn(), | ||
| 51 | ensureEnvironmentConnectionBootstrapped: async () => undefined, | ||
| 52 | getPrimaryEnvironmentConnection: () => primaryConnection, | ||
| 53 | readEnvironmentConnection: () => primaryConnection, | ||
| 54 | reconnectSavedEnvironment: vi.fn(), | ||
| 55 | removeSavedEnvironment: vi.fn(), | ||
| 56 | requireEnvironmentConnection: () => primaryConnection, | ||
| 57 | resetEnvironmentServiceForTests: vi.fn(), | ||
| 58 | startEnvironmentConnectionService: vi.fn(), | ||
| 59 | subscribeEnvironmentConnections: () => () => {}, | ||
| 60 | useSavedEnvironmentRegistryStore: ( | ||
| 61 | selector: (state: { byId: Record<string, never> }) => unknown, | ||
| 62 | ) => selector({ byId: {} }), | ||
| 63 | useSavedEnvironmentRuntimeStore: ( | ||
| 64 | selector: (state: { byId: Record<string, never> }) => unknown, | ||
| 65 | ) => selector({ byId: {} }), | ||
| 66 | }; | ||
| 67 | }); | ||
| 9 | 68 | ||
| 10 | function effort(value: string, isDefault = false) { | 69 | function effort(value: string, isDefault = false) { |
| 11 | return { | 70 | return { |
| ... | @@ -129,6 +188,21 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { | ... | @@ -129,6 +188,21 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { |
| 129 | }; | 188 | }; |
| 130 | } | 189 | } |
| 131 | 190 | ||
| 191 | function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { | ||
| 192 | return { | ||
| 193 | provider: "opencode", | ||
| 194 | enabled: true, | ||
| 195 | installed: true, | ||
| 196 | version: "1.0.0", | ||
| 197 | status: "ready", | ||
| 198 | auth: { status: "authenticated" }, | ||
| 199 | checkedAt: new Date().toISOString(), | ||
| 200 | models, | ||
| 201 | slashCommands: [], | ||
| 202 | skills: [], | ||
| 203 | }; | ||
| 204 | } | ||
| 205 | |||
| 132 | async function mountPicker(props: { | 206 | async function mountPicker(props: { |
| 133 | provider: ProviderKind; | 207 | provider: ProviderKind; |
| 134 | model: string; | 208 | model: string; |
| ... | @@ -168,12 +242,40 @@ async function mountPicker(props: { | ... | @@ -168,12 +242,40 @@ async function mountPicker(props: { |
| 168 | }; | 242 | }; |
| 169 | } | 243 | } |
| 170 | 244 | ||
| 245 | function getModelPickerListElement() { | ||
| 246 | const modelPickerList = document.querySelector<HTMLElement>(".model-picker-list"); | ||
| 247 | expect(modelPickerList).not.toBeNull(); | ||
| 248 | return modelPickerList!; | ||
| 249 | } | ||
| 250 | |||
| 251 | function getModelPickerListText() { | ||
| 252 | return getModelPickerListElement().textContent ?? ""; | ||
| 253 | } | ||
| 254 | |||
| 255 | function getVisibleModelNames() { | ||
| 256 | return Array.from(getModelPickerListElement().querySelectorAll<HTMLDivElement>("div.font-medium")) | ||
| 257 | .map((element) => element.textContent?.replace(/New$/u, "").trim() ?? "") | ||
| 258 | .filter((text) => text.length > 0); | ||
| 259 | } | ||
| 260 | |||
| 261 | function getSidebarProviderOrder() { | ||
| 262 | return Array.from(document.querySelectorAll<HTMLElement>("[data-model-picker-provider]")).map( | ||
| 263 | (element) => element.dataset.modelPickerProvider ?? "", | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | |||
| 171 | describe("ProviderModelPicker", () => { | 267 | describe("ProviderModelPicker", () => { |
| 172 | afterEach(() => { | 268 | beforeEach(async () => { |
| 269 | // Reset test environment before each test | ||
| 270 | await __resetLocalApiForTests(); | ||
| 271 | }); | ||
| 272 | |||
| 273 | afterEach(async () => { | ||
| 173 | document.body.innerHTML = ""; | 274 | document.body.innerHTML = ""; |
| 275 | await __resetLocalApiForTests(); | ||
| 174 | }); | 276 | }); |
| 175 | 277 | ||
| 176 | it("shows provider submenus when provider switching is allowed", async () => { | 278 | it("shows provider sidebar in unlocked mode", async () => { |
| 177 | const mounted = await mountPicker({ | 279 | const mounted = await mountPicker({ |
| 178 | provider: "claudeAgent", | 280 | provider: "claudeAgent", |
| 179 | model: "claude-opus-4-6", | 281 | model: "claude-opus-4-6", |
| ... | @@ -185,16 +287,16 @@ describe("ProviderModelPicker", () => { | ... | @@ -185,16 +287,16 @@ describe("ProviderModelPicker", () => { |
| 185 | 287 | ||
| 186 | await vi.waitFor(() => { | 288 | await vi.waitFor(() => { |
| 187 | const text = document.body.textContent ?? ""; | 289 | const text = document.body.textContent ?? ""; |
| 188 | expect(text).toContain("Codex"); | 290 | expect(text).not.toContain("Codex"); |
| 189 | expect(text).toContain("Claude"); | 291 | expect(text).toContain("Claude"); |
| 190 | expect(text).not.toContain("Claude Sonnet 4.6"); | 292 | expect(text).toContain("Claude Opus 4.6"); |
| 191 | }); | 293 | }); |
| 192 | } finally { | 294 | } finally { |
| 193 | await mounted.cleanup(); | 295 | await mounted.cleanup(); |
| 194 | } | 296 | } |
| 195 | }); | 297 | }); |
| 196 | 298 | ||
| 197 | it("opens provider submenus with a visible gap from the parent menu", async () => { | 299 | it("shows favorites first in the provider sidebar", async () => { |
| 198 | const mounted = await mountPicker({ | 300 | const mounted = await mountPicker({ |
| 199 | provider: "claudeAgent", | 301 | provider: "claudeAgent", |
| 200 | model: "claude-opus-4-6", | 302 | model: "claude-opus-4-6", |
| ... | @@ -203,43 +305,575 @@ describe("ProviderModelPicker", () => { | ... | @@ -203,43 +305,575 @@ describe("ProviderModelPicker", () => { |
| 203 | 305 | ||
| 204 | try { | 306 | try { |
| 205 | await page.getByRole("button").click(); | 307 | await page.getByRole("button").click(); |
| 206 | const providerTrigger = page.getByRole("menuitem", { name: "Codex" }); | ||
| 207 | await providerTrigger.hover(); | ||
| 208 | 308 | ||
| 209 | await vi.waitFor(() => { | 309 | await vi.waitFor(() => { |
| 210 | expect(document.body.textContent ?? "").toContain("GPT-5 Codex"); | 310 | expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ |
| 311 | "favorites", | ||
| 312 | "codex", | ||
| 313 | "claudeAgent", | ||
| 314 | ]); | ||
| 211 | }); | 315 | }); |
| 316 | } finally { | ||
| 317 | await mounted.cleanup(); | ||
| 318 | } | ||
| 319 | }); | ||
| 212 | 320 | ||
| 213 | const providerTriggerElement = Array.from( | 321 | it("filters models by selected provider in sidebar", async () => { |
| 214 | document.querySelectorAll<HTMLElement>('[role="menuitem"]'), | 322 | const mounted = await mountPicker({ |
| 215 | ).find((element) => element.textContent?.includes("Codex")); | 323 | provider: "claudeAgent", |
| 216 | if (!providerTriggerElement) { | 324 | model: "claude-opus-4-6", |
| 217 | throw new Error("Expected the Codex provider trigger to be mounted."); | 325 | lockedProvider: null, |
| 218 | } | 326 | }); |
| 219 | 327 | ||
| 220 | const providerTriggerRect = providerTriggerElement.getBoundingClientRect(); | 328 | try { |
| 221 | const modelElement = Array.from( | 329 | await page.getByRole("button").click(); |
| 222 | document.querySelectorAll<HTMLElement>('[role="menuitemradio"]'), | ||
| 223 | ).find((element) => element.textContent?.includes("GPT-5 Codex")); | ||
| 224 | if (!modelElement) { | ||
| 225 | throw new Error("Expected the submenu model option to be mounted."); | ||
| 226 | } | ||
| 227 | 330 | ||
| 228 | const submenuPopup = modelElement.closest('[data-slot="menu-sub-content"]'); | 331 | // Start with Claude models visible |
| 229 | if (!(submenuPopup instanceof HTMLElement)) { | 332 | await vi.waitFor(() => { |
| 230 | throw new Error("Expected submenu popup to be mounted."); | 333 | const text = document.body.textContent ?? ""; |
| 231 | } | 334 | expect(text).not.toContain("GPT-5 Codex"); |
| 335 | expect(text).toContain("Claude Opus 4.6"); | ||
| 336 | }); | ||
| 232 | 337 | ||
| 233 | const submenuRect = submenuPopup.getBoundingClientRect(); | 338 | // Click on Codex provider in sidebar |
| 339 | await vi.waitFor(() => { | ||
| 340 | expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); | ||
| 341 | }); | ||
| 342 | await page.getByRole("button", { name: "Codex", exact: true }).click(); | ||
| 234 | 343 | ||
| 235 | expect(submenuRect.left).toBeGreaterThanOrEqual(providerTriggerRect.right); | 344 | // Now should only show Codex models |
| 236 | expect(submenuRect.left - providerTriggerRect.right).toBeGreaterThanOrEqual(2); | 345 | await vi.waitFor(() => { |
| 346 | const listText = getModelPickerListText(); | ||
| 347 | expect(listText).toContain("GPT-5 Codex"); | ||
| 348 | expect(listText).not.toContain("Claude Opus 4.6"); | ||
| 349 | }); | ||
| 237 | } finally { | 350 | } finally { |
| 238 | await mounted.cleanup(); | 351 | await mounted.cleanup(); |
| 239 | } | 352 | } |
| 240 | }); | 353 | }); |
| 241 | 354 | ||
| 242 | it("shows models directly when the provider is locked mid-thread", async () => { | 355 | it("focuses the search input after selecting a sidebar provider", async () => { |
| 356 | const mounted = await mountPicker({ | ||
| 357 | provider: "claudeAgent", | ||
| 358 | model: "claude-opus-4-6", | ||
| 359 | lockedProvider: null, | ||
| 360 | }); | ||
| 361 | |||
| 362 | try { | ||
| 363 | await page.getByRole("button").click(); | ||
| 364 | |||
| 365 | await vi.waitFor(() => { | ||
| 366 | expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); | ||
| 367 | }); | ||
| 368 | await page.getByRole("button", { name: "Codex", exact: true }).click(); | ||
| 369 | |||
| 370 | await vi.waitFor(() => { | ||
| 371 | const searchInput = document.querySelector<HTMLInputElement>( | ||
| 372 | 'input[placeholder="Search models..."]', | ||
| 373 | ); | ||
| 374 | expect(searchInput).not.toBeNull(); | ||
| 375 | expect(document.activeElement).toBe(searchInput); | ||
| 376 | }); | ||
| 377 | } finally { | ||
| 378 | await mounted.cleanup(); | ||
| 379 | } | ||
| 380 | }); | ||
| 381 | |||
| 382 | it("shows locked provider header and only its models in locked mode", async () => { | ||
| 383 | localStorage.setItem( | ||
| 384 | "t3code:client-settings:v1", | ||
| 385 | JSON.stringify({ | ||
| 386 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 387 | favorites: [ | ||
| 388 | { provider: "codex", model: "gpt-5-codex" }, | ||
| 389 | { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | ||
| 390 | ], | ||
| 391 | }), | ||
| 392 | ); | ||
| 393 | |||
| 394 | const mounted = await mountPicker({ | ||
| 395 | provider: "claudeAgent", | ||
| 396 | model: "claude-opus-4-6", | ||
| 397 | lockedProvider: "claudeAgent", | ||
| 398 | }); | ||
| 399 | |||
| 400 | try { | ||
| 401 | await page.getByRole("button").click(); | ||
| 402 | |||
| 403 | await vi.waitFor(() => { | ||
| 404 | const text = document.body.textContent ?? ""; | ||
| 405 | // Should show locked provider label | ||
| 406 | expect(text).toContain("Claude"); | ||
| 407 | expect(getVisibleModelNames()).toEqual([ | ||
| 408 | "Claude Sonnet 4.6", | ||
| 409 | "Claude Opus 4.6", | ||
| 410 | "Claude Haiku 4.5", | ||
| 411 | ]); | ||
| 412 | }); | ||
| 413 | } finally { | ||
| 414 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 415 | await mounted.cleanup(); | ||
| 416 | } | ||
| 417 | }); | ||
| 418 | |||
| 419 | it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { | ||
| 420 | const host = document.createElement("div"); | ||
| 421 | document.body.append(host); | ||
| 422 | const onProviderModelChange = vi.fn(); | ||
| 423 | const modelOptionsByProvider = { | ||
| 424 | claudeAgent: [ | ||
| 425 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | ||
| 426 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | ||
| 427 | ], | ||
| 428 | codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }], | ||
| 429 | cursor: [], | ||
| 430 | opencode: [], | ||
| 431 | } as const; | ||
| 432 | const screen = await render( | ||
| 433 | <ProviderModelPicker | ||
| 434 | provider="claudeAgent" | ||
| 435 | model="gpt-5-codex" | ||
| 436 | lockedProvider={null} | ||
| 437 | providers={TEST_PROVIDERS} | ||
| 438 | modelOptionsByProvider={modelOptionsByProvider} | ||
| 439 | onProviderModelChange={onProviderModelChange} | ||
| 440 | />, | ||
| 441 | { container: host }, | ||
| 442 | ); | ||
| 443 | |||
| 444 | try { | ||
| 445 | const trigger = document.querySelector<HTMLElement>( | ||
| 446 | '[data-chat-provider-model-picker="true"]', | ||
| 447 | ); | ||
| 448 | expect(trigger).not.toBeNull(); | ||
| 449 | const label = trigger?.textContent ?? ""; | ||
| 450 | expect(label).not.toContain("gpt-5-codex"); | ||
| 451 | expect(label).toContain("Claude Opus 4.6"); | ||
| 452 | } finally { | ||
| 453 | await screen.unmount(); | ||
| 454 | host.remove(); | ||
| 455 | } | ||
| 456 | }); | ||
| 457 | |||
| 458 | it("uses the trigger label for locked opencode rows", async () => { | ||
| 459 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 460 | buildOpenCodeProvider([ | ||
| 461 | { | ||
| 462 | slug: "github-copilot/claude-opus-4.5", | ||
| 463 | name: "Claude Opus 4.5", | ||
| 464 | subProvider: "GitHub Copilot", | ||
| 465 | shortName: "Opus 4.5", | ||
| 466 | isCustom: false, | ||
| 467 | capabilities: { | ||
| 468 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | ||
| 469 | supportsFastMode: false, | ||
| 470 | supportsThinkingToggle: false, | ||
| 471 | contextWindowOptions: [], | ||
| 472 | promptInjectedEffortLevels: [], | ||
| 473 | }, | ||
| 474 | }, | ||
| 475 | ]), | ||
| 476 | ]; | ||
| 477 | const mounted = await mountPicker({ | ||
| 478 | provider: "opencode", | ||
| 479 | model: "github-copilot/claude-opus-4.5", | ||
| 480 | lockedProvider: "opencode", | ||
| 481 | providers, | ||
| 482 | }); | ||
| 483 | |||
| 484 | try { | ||
| 485 | await vi.waitFor(() => { | ||
| 486 | const trigger = document.querySelector<HTMLElement>( | ||
| 487 | '[data-chat-provider-model-picker="true"]', | ||
| 488 | ); | ||
| 489 | expect(trigger?.textContent).toContain("GitHub Copilot"); | ||
| 490 | expect(trigger?.textContent).toContain("Opus 4.5"); | ||
| 491 | }); | ||
| 492 | |||
| 493 | await page.getByRole("button").click(); | ||
| 494 | |||
| 495 | await vi.waitFor(() => { | ||
| 496 | expect(getVisibleModelNames()).toEqual(["GitHub Copilot · Opus 4.5"]); | ||
| 497 | }); | ||
| 498 | } finally { | ||
| 499 | await mounted.cleanup(); | ||
| 500 | } | ||
| 501 | }); | ||
| 502 | |||
| 503 | it("searches models by name in flat list", async () => { | ||
| 504 | const mounted = await mountPicker({ | ||
| 505 | provider: "claudeAgent", | ||
| 506 | model: "claude-opus-4-6", | ||
| 507 | lockedProvider: null, | ||
| 508 | }); | ||
| 509 | |||
| 510 | try { | ||
| 511 | await page.getByRole("button").click(); | ||
| 512 | |||
| 513 | await vi.waitFor(() => { | ||
| 514 | const text = document.body.textContent ?? ""; | ||
| 515 | expect(text).toContain("Claude Opus 4.6"); | ||
| 516 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 517 | }); | ||
| 518 | |||
| 519 | // Find and type in search box | ||
| 520 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 521 | await searchInput.fill("claude"); | ||
| 522 | |||
| 523 | await vi.waitFor(() => { | ||
| 524 | const text = document.body.textContent ?? ""; | ||
| 525 | expect(text).toContain("Claude Opus 4.6"); | ||
| 526 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 527 | }); | ||
| 528 | } finally { | ||
| 529 | await mounted.cleanup(); | ||
| 530 | } | ||
| 531 | }); | ||
| 532 | |||
| 533 | it("supports arrow-key navigation in the model picker", async () => { | ||
| 534 | const mounted = await mountPicker({ | ||
| 535 | provider: "claudeAgent", | ||
| 536 | model: "claude-opus-4-6", | ||
| 537 | lockedProvider: "claudeAgent", | ||
| 538 | }); | ||
| 539 | |||
| 540 | try { | ||
| 541 | await page.getByRole("button").click(); | ||
| 542 | |||
| 543 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 544 | await userEvent.click(searchInput); | ||
| 545 | await userEvent.keyboard("{ArrowDown}"); | ||
| 546 | await vi.waitFor(() => { | ||
| 547 | const highlightedItem = document.querySelector<HTMLElement>( | ||
| 548 | '[data-slot="combobox-item"][data-highlighted]', | ||
| 549 | ); | ||
| 550 | expect(highlightedItem).not.toBeNull(); | ||
| 551 | expect(highlightedItem?.textContent).toContain("Claude Opus 4.6"); | ||
| 552 | }); | ||
| 553 | await userEvent.keyboard("{ArrowDown}"); | ||
| 554 | await vi.waitFor(() => { | ||
| 555 | const highlightedItem = document.querySelector<HTMLElement>( | ||
| 556 | '[data-slot="combobox-item"][data-highlighted]', | ||
| 557 | ); | ||
| 558 | expect(highlightedItem).not.toBeNull(); | ||
| 559 | expect(highlightedItem?.textContent).toContain("Claude Sonnet 4.6"); | ||
| 560 | }); | ||
| 561 | await userEvent.keyboard("{Enter}"); | ||
| 562 | |||
| 563 | expect(mounted.onProviderModelChange).toHaveBeenCalledWith( | ||
| 564 | "claudeAgent", | ||
| 565 | "claude-sonnet-4-6", | ||
| 566 | ); | ||
| 567 | } finally { | ||
| 568 | await mounted.cleanup(); | ||
| 569 | } | ||
| 570 | }); | ||
| 571 | |||
| 572 | it("hides the provider sidebar while searching", async () => { | ||
| 573 | const mounted = await mountPicker({ | ||
| 574 | provider: "claudeAgent", | ||
| 575 | model: "claude-opus-4-6", | ||
| 576 | lockedProvider: null, | ||
| 577 | }); | ||
| 578 | |||
| 579 | try { | ||
| 580 | await page.getByRole("button").click(); | ||
| 581 | |||
| 582 | await vi.waitFor(() => { | ||
| 583 | expect(getSidebarProviderOrder().length).toBeGreaterThan(0); | ||
| 584 | }); | ||
| 585 | |||
| 586 | await page.getByPlaceholder("Search models...").fill("cla"); | ||
| 587 | |||
| 588 | await vi.waitFor(() => { | ||
| 589 | expect(getSidebarProviderOrder()).toEqual([]); | ||
| 590 | }); | ||
| 591 | } finally { | ||
| 592 | await mounted.cleanup(); | ||
| 593 | } | ||
| 594 | }); | ||
| 595 | |||
| 596 | it("closes the picker when escape is pressed in search", async () => { | ||
| 597 | const mounted = await mountPicker({ | ||
| 598 | provider: "claudeAgent", | ||
| 599 | model: "claude-opus-4-6", | ||
| 600 | lockedProvider: null, | ||
| 601 | }); | ||
| 602 | |||
| 603 | try { | ||
| 604 | await page.getByRole("button").click(); | ||
| 605 | |||
| 606 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 607 | await searchInput.click(); | ||
| 608 | const searchInputElement = document.querySelector<HTMLInputElement>( | ||
| 609 | 'input[placeholder="Search models..."]', | ||
| 610 | ); | ||
| 611 | expect(searchInputElement).not.toBeNull(); | ||
| 612 | searchInputElement!.dispatchEvent( | ||
| 613 | new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), | ||
| 614 | ); | ||
| 615 | |||
| 616 | await vi.waitFor(() => { | ||
| 617 | expect(document.querySelector(".model-picker-list")).toBeNull(); | ||
| 618 | }); | ||
| 619 | } finally { | ||
| 620 | await mounted.cleanup(); | ||
| 621 | } | ||
| 622 | }); | ||
| 623 | |||
| 624 | it("searches models by provider name", async () => { | ||
| 625 | const mounted = await mountPicker({ | ||
| 626 | provider: "claudeAgent", | ||
| 627 | model: "claude-opus-4-6", | ||
| 628 | lockedProvider: null, | ||
| 629 | }); | ||
| 630 | |||
| 631 | try { | ||
| 632 | await page.getByRole("button").click(); | ||
| 633 | |||
| 634 | await vi.waitFor(() => { | ||
| 635 | const text = document.body.textContent ?? ""; | ||
| 636 | expect(text).toContain("Claude Opus 4.6"); | ||
| 637 | expect(text).not.toContain("GPT-5 Codex"); | ||
| 638 | }); | ||
| 639 | |||
| 640 | // Search by provider name | ||
| 641 | const searchInput = page.getByPlaceholder("Search models..."); | ||
| 642 | await searchInput.fill("codex"); | ||
| 643 | |||
| 644 | await vi.waitFor(() => { | ||
| 645 | const listText = getModelPickerListText(); | ||
| 646 | expect(listText).toContain("GPT-5 Codex"); | ||
| 647 | expect(listText).not.toContain("Claude Opus 4.6"); | ||
| 648 | }); | ||
| 649 | } finally { | ||
| 650 | await mounted.cleanup(); | ||
| 651 | } | ||
| 652 | }); | ||
| 653 | |||
| 654 | it("matches fuzzy multi-token queries across provider and model text", async () => { | ||
| 655 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 656 | buildCodexProvider([ | ||
| 657 | { | ||
| 658 | slug: "gpt-5-codex", | ||
| 659 | name: "GPT-5 Codex", | ||
| 660 | isCustom: false, | ||
| 661 | capabilities: { | ||
| 662 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | ||
| 663 | supportsFastMode: true, | ||
| 664 | supportsThinkingToggle: false, | ||
| 665 | contextWindowOptions: [], | ||
| 666 | promptInjectedEffortLevels: [], | ||
| 667 | }, | ||
| 668 | }, | ||
| 669 | ]), | ||
| 670 | buildOpenCodeProvider([ | ||
| 671 | { | ||
| 672 | slug: "github-copilot/claude-opus-4.7", | ||
| 673 | name: "Claude Opus 4.7", | ||
| 674 | subProvider: "GitHub Copilot", | ||
| 675 | isCustom: false, | ||
| 676 | capabilities: { | ||
| 677 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | ||
| 678 | supportsFastMode: false, | ||
| 679 | supportsThinkingToggle: false, | ||
| 680 | contextWindowOptions: [], | ||
| 681 | promptInjectedEffortLevels: [], | ||
| 682 | }, | ||
| 683 | }, | ||
| 684 | ]), | ||
| 685 | ]; | ||
| 686 | const mounted = await mountPicker({ | ||
| 687 | provider: "opencode", | ||
| 688 | model: "github-copilot/claude-opus-4.7", | ||
| 689 | lockedProvider: null, | ||
| 690 | providers, | ||
| 691 | }); | ||
| 692 | |||
| 693 | try { | ||
| 694 | await page.getByRole("button").click(); | ||
| 695 | await page.getByPlaceholder("Search models...").fill("coplt op"); | ||
| 696 | |||
| 697 | await vi.waitFor(() => { | ||
| 698 | const listText = getModelPickerListText(); | ||
| 699 | expect(listText).toContain("Claude Opus 4.7"); | ||
| 700 | expect(listText).not.toContain("GPT-5 Codex"); | ||
| 701 | }); | ||
| 702 | } finally { | ||
| 703 | await mounted.cleanup(); | ||
| 704 | } | ||
| 705 | }); | ||
| 706 | |||
| 707 | it("renders each search result with its own provider branding", async () => { | ||
| 708 | const providers: ReadonlyArray<ServerProvider> = [ | ||
| 709 | buildOpenCodeProvider([ | ||
| 710 | { | ||
| 711 | slug: "github-copilot/claude-opus-4.7", | ||
| 712 | name: "Claude Opus 4.7", | ||
| 713 | subProvider: "GitHub Copilot", | ||
| 714 | isCustom: false, | ||
| 715 | capabilities: { | ||
| 716 | reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], | ||
| 717 | supportsFastMode: false, | ||
| 718 | supportsThinkingToggle: false, | ||
| 719 | contextWindowOptions: [], | ||
| 720 | promptInjectedEffortLevels: [], | ||
| 721 | }, | ||
| 722 | }, | ||
| 723 | ]), | ||
| 724 | { | ||
| 725 | ...TEST_PROVIDERS[1]!, | ||
| 726 | models: [ | ||
| 727 | { | ||
| 728 | slug: "claude-opus-4-6", | ||
| 729 | name: "Claude Opus 4.6", | ||
| 730 | isCustom: false, | ||
| 731 | capabilities: { | ||
| 732 | reasoningEffortLevels: [ | ||
| 733 | effort("low"), | ||
| 734 | effort("medium", true), | ||
| 735 | effort("high"), | ||
| 736 | effort("max"), | ||
| 737 | ], | ||
| 738 | supportsFastMode: false, | ||
| 739 | supportsThinkingToggle: true, | ||
| 740 | contextWindowOptions: [], | ||
| 741 | promptInjectedEffortLevels: [], | ||
| 742 | }, | ||
| 743 | }, | ||
| 744 | ], | ||
| 745 | }, | ||
| 746 | ]; | ||
| 747 | const mounted = await mountPicker({ | ||
| 748 | provider: "opencode", | ||
| 749 | model: "github-copilot/claude-opus-4.7", | ||
| 750 | lockedProvider: null, | ||
| 751 | providers, | ||
| 752 | }); | ||
| 753 | |||
| 754 | try { | ||
| 755 | await page.getByRole("button").click(); | ||
| 756 | await page.getByPlaceholder("Search models...").fill("opus"); | ||
| 757 | |||
| 758 | await vi.waitFor(() => { | ||
| 759 | const listText = getModelPickerListText(); | ||
| 760 | expect(listText).toContain("OpenCode · GitHub Copilot"); | ||
| 761 | expect(listText).toContain("Claude"); | ||
| 762 | expect(listText).not.toContain("OpenCodeClaude Opus 4.6"); | ||
| 763 | }); | ||
| 764 | } finally { | ||
| 765 | await mounted.cleanup(); | ||
| 766 | } | ||
| 767 | }); | ||
| 768 | |||
| 769 | it("toggles favorite stars when clicked", async () => { | ||
| 770 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 771 | |||
| 772 | const mounted = await mountPicker({ | ||
| 773 | provider: "claudeAgent", | ||
| 774 | model: "claude-opus-4-6", | ||
| 775 | lockedProvider: null, | ||
| 776 | }); | ||
| 777 | |||
| 778 | try { | ||
| 779 | await page.getByRole("button").click(); | ||
| 780 | |||
| 781 | await vi.waitFor(() => { | ||
| 782 | const text = document.body.textContent ?? ""; | ||
| 783 | expect(text).toContain("Claude Opus 4.6"); | ||
| 784 | }); | ||
| 785 | |||
| 786 | const getFirstStarButton = () => { | ||
| 787 | const starButton = document.querySelector<HTMLButtonElement>( | ||
| 788 | 'button[aria-label*="favorites"]', | ||
| 789 | ); | ||
| 790 | expect(starButton).not.toBeNull(); | ||
| 791 | return starButton!; | ||
| 792 | }; | ||
| 793 | |||
| 794 | const firstStar = getFirstStarButton(); | ||
| 795 | const initialAriaLabel = firstStar.getAttribute("aria-label"); | ||
| 796 | expect( | ||
| 797 | initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", | ||
| 798 | ).toBe(true); | ||
| 799 | |||
| 800 | await page.getByRole("button", { name: initialAriaLabel! }).first().click(); | ||
| 801 | |||
| 802 | const expectedAriaLabel = | ||
| 803 | initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; | ||
| 804 | |||
| 805 | await vi.waitFor(() => { | ||
| 806 | expect(getFirstStarButton().getAttribute("aria-label")).toBe(expectedAriaLabel); | ||
| 807 | }); | ||
| 808 | } finally { | ||
| 809 | await mounted.cleanup(); | ||
| 810 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 811 | } | ||
| 812 | }); | ||
| 813 | |||
| 814 | it("does not duplicate favorited models across favorites and all models sections", async () => { | ||
| 815 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 816 | |||
| 817 | const mounted = await mountPicker({ | ||
| 818 | provider: "claudeAgent", | ||
| 819 | model: "claude-opus-4-6", | ||
| 820 | lockedProvider: null, | ||
| 821 | }); | ||
| 822 | |||
| 823 | try { | ||
| 824 | await page.getByRole("button").click(); | ||
| 825 | |||
| 826 | await vi.waitFor(() => { | ||
| 827 | const text = document.body.textContent ?? ""; | ||
| 828 | expect(text).toContain("Claude Opus 4.6"); | ||
| 829 | }); | ||
| 830 | |||
| 831 | const favoriteButton = page.getByRole("button", { | ||
| 832 | name: "Add to favorites", | ||
| 833 | }); | ||
| 834 | await favoriteButton.first().click(); | ||
| 835 | |||
| 836 | await vi.waitFor(async () => { | ||
| 837 | const favoritedModelRows = Array.from( | ||
| 838 | getModelPickerListElement().querySelectorAll<HTMLDivElement>("div.font-medium"), | ||
| 839 | ).filter((element) => element.textContent?.trim() === "Claude Opus 4.6"); | ||
| 840 | expect(favoritedModelRows.length).toBe(1); | ||
| 841 | }); | ||
| 842 | } finally { | ||
| 843 | await mounted.cleanup(); | ||
| 844 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 845 | } | ||
| 846 | }); | ||
| 847 | |||
| 848 | it("shows favorited models first within the selected provider list", async () => { | ||
| 849 | localStorage.setItem( | ||
| 850 | "t3code:client-settings:v1", | ||
| 851 | JSON.stringify({ | ||
| 852 | ...DEFAULT_CLIENT_SETTINGS, | ||
| 853 | favorites: [{ provider: "codex", model: "gpt-5.3-codex" }], | ||
| 854 | }), | ||
| 855 | ); | ||
| 856 | |||
| 857 | const mounted = await mountPicker({ | ||
| 858 | provider: "codex", | ||
| 859 | model: "gpt-5-codex", | ||
| 860 | lockedProvider: null, | ||
| 861 | }); | ||
| 862 | |||
| 863 | try { | ||
| 864 | await page.getByRole("button").click(); | ||
| 865 | await page.getByRole("button", { name: "Codex", exact: true }).click(); | ||
| 866 | |||
| 867 | await vi.waitFor(() => { | ||
| 868 | expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5.3 Codex", "GPT-5 Codex"]); | ||
| 869 | }); | ||
| 870 | } finally { | ||
| 871 | await mounted.cleanup(); | ||
| 872 | localStorage.removeItem("t3code:client-settings:v1"); | ||
| 873 | } | ||
| 874 | }); | ||
| 875 | |||
| 876 | it("dispatches callback with correct provider and model when selected", async () => { | ||
| 243 | const mounted = await mountPicker({ | 877 | const mounted = await mountPicker({ |
| 244 | provider: "claudeAgent", | 878 | provider: "claudeAgent", |
| 245 | model: "claude-opus-4-6", | 879 | model: "claude-opus-4-6", |
| ... | @@ -252,15 +886,23 @@ describe("ProviderModelPicker", () => { | ... | @@ -252,15 +886,23 @@ describe("ProviderModelPicker", () => { |
| 252 | await vi.waitFor(() => { | 886 | await vi.waitFor(() => { |
| 253 | const text = document.body.textContent ?? ""; | 887 | const text = document.body.textContent ?? ""; |
| 254 | expect(text).toContain("Claude Sonnet 4.6"); | 888 | expect(text).toContain("Claude Sonnet 4.6"); |
| 255 | expect(text).toContain("Claude Haiku 4.5"); | ||
| 256 | expect(text).not.toContain("Codex"); | ||
| 257 | }); | 889 | }); |
| 890 | |||
| 891 | // Click on a model | ||
| 892 | const modelRow = page.getByText("Claude Sonnet 4.6").first(); | ||
| 893 | await modelRow.click(); | ||
| 894 | |||
| 895 | // Verify callback was called with correct values | ||
| 896 | expect(mounted.onProviderModelChange).toHaveBeenCalledWith( | ||
| 897 | "claudeAgent", | ||
| 898 | "claude-sonnet-4-6", | ||
| 899 | ); | ||
| 258 | } finally { | 900 | } finally { |
| 259 | await mounted.cleanup(); | 901 | await mounted.cleanup(); |
| 260 | } | 902 | } |
| 261 | }); | 903 | }); |
| 262 | 904 | ||
| 263 | it("only shows codex spark when the server reports it for the account", async () => { | 905 | it("only shows codex spark when the server reports it", async () => { |
| 264 | const providersWithoutSpark: ReadonlyArray<ServerProvider> = [ | 906 | const providersWithoutSpark: ReadonlyArray<ServerProvider> = [ |
| 265 | buildCodexProvider([ | 907 | buildCodexProvider([ |
| 266 | { | 908 | { |
| ... | @@ -309,15 +951,14 @@ describe("ProviderModelPicker", () => { | ... | @@ -309,15 +951,14 @@ describe("ProviderModelPicker", () => { |
| 309 | ]; | 951 | ]; |
| 310 | 952 | ||
| 311 | const hidden = await mountPicker({ | 953 | const hidden = await mountPicker({ |
| 312 | provider: "claudeAgent", | 954 | provider: "codex", |
| 313 | model: "claude-opus-4-6", | 955 | model: "gpt-5.3-codex", |
| 314 | lockedProvider: null, | 956 | lockedProvider: null, |
| 315 | providers: providersWithoutSpark, | 957 | providers: providersWithoutSpark, |
| 316 | }); | 958 | }); |
| 317 | 959 | ||
| 318 | try { | 960 | try { |
| 319 | await page.getByRole("button").click(); | 961 | await page.getByRole("button").click(); |
| 320 | await page.getByRole("menuitem", { name: "Codex" }).hover(); | ||
| 321 | 962 | ||
| 322 | await vi.waitFor(() => { | 963 | await vi.waitFor(() => { |
| 323 | const text = document.body.textContent ?? ""; | 964 | const text = document.body.textContent ?? ""; |
| ... | @@ -329,15 +970,14 @@ describe("ProviderModelPicker", () => { | ... | @@ -329,15 +970,14 @@ describe("ProviderModelPicker", () => { |
| 329 | } | 970 | } |
| 330 | 971 | ||
| 331 | const visible = await mountPicker({ | 972 | const visible = await mountPicker({ |
| 332 | provider: "claudeAgent", | 973 | provider: "codex", |
| 333 | model: "claude-opus-4-6", | 974 | model: "gpt-5.3-codex", |
| 334 | lockedProvider: null, | 975 | lockedProvider: null, |
| 335 | providers: providersWithSpark, | 976 | providers: providersWithSpark, |
| 336 | }); | 977 | }); |
| 337 | 978 | ||
| 338 | try { | 979 | try { |
| 339 | await page.getByRole("button").click(); | 980 | await page.getByRole("button").click(); |
| 340 | await page.getByRole("menuitem", { name: "Codex" }).hover(); | ||
| 341 | 981 | ||
| 342 | await vi.waitFor(() => { | 982 | await vi.waitFor(() => { |
| 343 | expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); | 983 | expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); |
| ... | @@ -347,27 +987,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -347,27 +987,7 @@ describe("ProviderModelPicker", () => { |
| 347 | } | 987 | } |
| 348 | }); | 988 | }); |
| 349 | 989 | ||
| 350 | it("dispatches the canonical slug when a model is selected", async () => { | 990 | it("shows disabled providers grayed out in sidebar", async () => { |
| 351 | const mounted = await mountPicker({ | ||
| 352 | provider: "claudeAgent", | ||
| 353 | model: "claude-opus-4-6", | ||
| 354 | lockedProvider: "claudeAgent", | ||
| 355 | }); | ||
| 356 | |||
| 357 | try { | ||
| 358 | await page.getByRole("button").click(); | ||
| 359 | await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click(); | ||
| 360 | |||
| 361 | expect(mounted.onProviderModelChange).toHaveBeenCalledWith( | ||
| 362 | "claudeAgent", | ||
| 363 | "claude-sonnet-4-6", | ||
| 364 | ); | ||
| 365 | } finally { | ||
| 366 | await mounted.cleanup(); | ||
| 367 | } | ||
| 368 | }); | ||
| 369 | |||
| 370 | it("shows disabled providers as non-selectable entries", async () => { | ||
| 371 | const disabledProviders = TEST_PROVIDERS.slice(); | 991 | const disabledProviders = TEST_PROVIDERS.slice(); |
| 372 | const claudeIndex = disabledProviders.findIndex( | 992 | const claudeIndex = disabledProviders.findIndex( |
| 373 | (provider) => provider.provider === "claudeAgent", | 993 | (provider) => provider.provider === "claudeAgent", |
| ... | @@ -380,6 +1000,7 @@ describe("ProviderModelPicker", () => { | ... | @@ -380,6 +1000,7 @@ describe("ProviderModelPicker", () => { |
| 380 | status: "disabled", | 1000 | status: "disabled", |
| 381 | }; | 1001 | }; |
| 382 | } | 1002 | } |
| 1003 | |||
| 383 | const mounted = await mountPicker({ | 1004 | const mounted = await mountPicker({ |
| 384 | provider: "codex", | 1005 | provider: "codex", |
| 385 | model: "gpt-5-codex", | 1006 | model: "gpt-5-codex", |
| ... | @@ -392,9 +1013,9 @@ describe("ProviderModelPicker", () => { | ... | @@ -392,9 +1013,9 @@ describe("ProviderModelPicker", () => { |
| 392 | 1013 | ||
| 393 | await vi.waitFor(() => { | 1014 | await vi.waitFor(() => { |
| 394 | const text = document.body.textContent ?? ""; | 1015 | const text = document.body.textContent ?? ""; |
| 395 | expect(text).toContain("Claude"); | 1016 | expect(text).toContain("GPT-5 Codex"); |
| 396 | expect(text).toContain("Disabled"); | 1017 | // Disabled provider should not have its models shown |
| 397 | expect(text).not.toContain("Claude Sonnet 4.6"); | 1018 | expect(text).not.toContain("Claude Opus 4.6"); |
| 398 | }); | 1019 | }); |
| 399 | } finally { | 1020 | } finally { |
| 400 | await mounted.cleanup(); | 1021 | await mounted.cleanup(); |
apps/web/src/components/chat/ProviderModelPicker.tsx+97-180| ... | @@ -1,87 +1,78 @@ | ... | @@ -1,87 +1,78 @@ |
| 1 | import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; | 1 | import { |
| 2 | import { resolveSelectableModel } from "@t3tools/shared/model"; | 2 | type ProviderKind, |
| 3 | import { memo, useState } from "react"; | 3 | type ResolvedKeybindingsConfig, |
| 4 | type ServerProvider, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { memo, useEffect, useState } from "react"; | ||
| 4 | import type { VariantProps } from "class-variance-authority"; | 7 | import type { VariantProps } from "class-variance-authority"; |
| 5 | import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; | ||
| 6 | import { ChevronDownIcon } from "lucide-react"; | 8 | import { ChevronDownIcon } from "lucide-react"; |
| 7 | import { Button, buttonVariants } from "../ui/button"; | 9 | import { Button, buttonVariants } from "../ui/button"; |
| 8 | import { | 10 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 9 | Menu, | 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 10 | MenuGroup, | ||
| 11 | MenuItem, | ||
| 12 | MenuPopup, | ||
| 13 | MenuRadioGroup, | ||
| 14 | MenuRadioItem, | ||
| 15 | MenuSeparator as MenuDivider, | ||
| 16 | MenuSub, | ||
| 17 | MenuSubPopup, | ||
| 18 | MenuSubTrigger, | ||
| 19 | MenuTrigger, | ||
| 20 | } from "../ui/menu"; | ||
| 21 | import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | ||
| 22 | import { cn } from "~/lib/utils"; | 12 | import { cn } from "~/lib/utils"; |
| 23 | import { getProviderSnapshot } from "../../providerModels"; | 13 | import { ModelPickerContent } from "./ModelPickerContent"; |
| 24 | 14 | import { | |
| 25 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | 15 | ModelEsque, |
| 26 | value: ProviderKind; | 16 | PROVIDER_ICON_BY_PROVIDER, |
| 27 | label: string; | 17 | getTriggerDisplayModelLabel, |
| 28 | available: true; | 18 | getTriggerDisplayModelName, |
| 29 | } { | 19 | } from "./providerIconUtils"; |
| 30 | return option.available; | 20 | import { setModelPickerOpen } from "../../modelPickerOpenState"; |
| 31 | } | ||
| 32 | |||
| 33 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { | ||
| 34 | codex: OpenAI, | ||
| 35 | claudeAgent: ClaudeAI, | ||
| 36 | opencode: OpenCodeIcon, | ||
| 37 | cursor: CursorIcon, | ||
| 38 | }; | ||
| 39 | |||
| 40 | export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); | ||
| 41 | const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); | ||
| 42 | const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const; | ||
| 43 | |||
| 44 | function providerIconClassName( | ||
| 45 | provider: ProviderKind | ProviderPickerKind, | ||
| 46 | fallbackClassName: string, | ||
| 47 | ): string { | ||
| 48 | return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; | ||
| 49 | } | ||
| 50 | 21 | ||
| 51 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | 22 | export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 52 | provider: ProviderKind; | 23 | provider: ProviderKind; |
| 53 | model: string; | 24 | model: string; |
| 54 | lockedProvider: ProviderKind | null; | 25 | lockedProvider: ProviderKind | null; |
| 55 | providers?: ReadonlyArray<ServerProvider>; | 26 | providers?: ReadonlyArray<ServerProvider>; |
| 56 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>; | 27 | keybindings?: ResolvedKeybindingsConfig; |
| 28 | modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<ModelEsque>>; | ||
| 57 | activeProviderIconClassName?: string; | 29 | activeProviderIconClassName?: string; |
| 58 | compact?: boolean; | 30 | compact?: boolean; |
| 59 | disabled?: boolean; | 31 | disabled?: boolean; |
| 32 | terminalOpen?: boolean; | ||
| 33 | open?: boolean; | ||
| 60 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; | 34 | triggerVariant?: VariantProps<typeof buttonVariants>["variant"]; |
| 61 | triggerClassName?: string; | 35 | triggerClassName?: string; |
| 36 | onOpenChange?: (open: boolean) => void; | ||
| 62 | onProviderModelChange: (provider: ProviderKind, model: string) => void; | 37 | onProviderModelChange: (provider: ProviderKind, model: string) => void; |
| 63 | }) { | 38 | }) { |
| 64 | const [isMenuOpen, setIsMenuOpen] = useState(false); | 39 | const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); |
| 65 | const activeProvider = props.lockedProvider ?? props.provider; | 40 | const activeProvider = props.lockedProvider ?? props.provider; |
| 41 | const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; | ||
| 66 | const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; | 42 | const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; |
| 67 | const selectedModelLabel = | 43 | // If the current slug belongs to a different provider (for example after a provider |
| 68 | selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; | 44 | // switch or disable), prefer the active provider's first option so the trigger icon |
| 45 | // and label stay in sync instead of showing a stale foreign slug. | ||
| 46 | const selectedModel = | ||
| 47 | selectedProviderOptions.find((option) => option.slug === props.model) ?? | ||
| 48 | selectedProviderOptions[0]; | ||
| 69 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; | 49 | const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; |
| 70 | const handleModelChange = (provider: ProviderKind, value: string) => { | 50 | const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; |
| 51 | const triggerSubtitle = selectedModel?.subProvider; | ||
| 52 | const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; | ||
| 53 | |||
| 54 | const setIsMenuOpen = (open: boolean) => { | ||
| 55 | props.onOpenChange?.(open); | ||
| 56 | if (props.open === undefined) { | ||
| 57 | setUncontrolledIsMenuOpen(open); | ||
| 58 | } | ||
| 59 | }; | ||
| 60 | |||
| 61 | useEffect(() => { | ||
| 62 | setModelPickerOpen(isMenuOpen); | ||
| 63 | return () => { | ||
| 64 | setModelPickerOpen(false); | ||
| 65 | }; | ||
| 66 | }, [isMenuOpen]); | ||
| 67 | |||
| 68 | const handleProviderModelChange = (provider: ProviderKind, model: string) => { | ||
| 71 | if (props.disabled) return; | 69 | if (props.disabled) return; |
| 72 | if (!value) return; | 70 | props.onProviderModelChange(provider, model); |
| 73 | const resolvedModel = resolveSelectableModel( | ||
| 74 | provider, | ||
| 75 | value, | ||
| 76 | props.modelOptionsByProvider[provider], | ||
| 77 | ); | ||
| 78 | if (!resolvedModel) return; | ||
| 79 | props.onProviderModelChange(provider, resolvedModel); | ||
| 80 | setIsMenuOpen(false); | 71 | setIsMenuOpen(false); |
| 81 | }; | 72 | }; |
| 82 | 73 | ||
| 83 | return ( | 74 | return ( |
| 84 | <Menu | 75 | <Popover |
| 85 | open={isMenuOpen} | 76 | open={isMenuOpen} |
| 86 | onOpenChange={(open) => { | 77 | onOpenChange={(open) => { |
| 87 | if (props.disabled) { | 78 | if (props.disabled) { |
| ... | @@ -91,7 +82,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -91,7 +82,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 91 | setIsMenuOpen(open); | 82 | setIsMenuOpen(open); |
| 92 | }} | 83 | }} |
| 93 | > | 84 | > |
| 94 | <MenuTrigger | 85 | <PopoverTrigger |
| 95 | render={ | 86 | render={ |
| 96 | <Button | 87 | <Button |
| 97 | size="sm" | 88 | size="sm" |
| ... | @@ -114,128 +105,54 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { | ... | @@ -114,128 +105,54 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { |
| 114 | > | 105 | > |
| 115 | <ProviderIcon | 106 | <ProviderIcon |
| 116 | aria-hidden="true" | 107 | aria-hidden="true" |
| 117 | className={cn( | 108 | className={cn("size-4 shrink-0", props.activeProviderIconClassName)} |
| 118 | "size-4 shrink-0", | ||
| 119 | providerIconClassName(activeProvider, "text-muted-foreground/70"), | ||
| 120 | props.activeProviderIconClassName, | ||
| 121 | )} | ||
| 122 | /> | 109 | /> |
| 123 | <span className="min-w-0 flex-1 truncate">{selectedModelLabel}</span> | 110 | <Tooltip> |
| 124 | <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" /> | 111 | <TooltipTrigger |
| 125 | </span> | 112 | render={ |
| 126 | </MenuTrigger> | 113 | <span |
| 127 | <MenuPopup align="start"> | 114 | className={cn( |
| 128 | {props.lockedProvider !== null ? ( | 115 | "min-w-0 flex-1 overflow-hidden", |
| 129 | <MenuGroup> | 116 | triggerSubtitle |
| 130 | <MenuRadioGroup | 117 | ? "grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-1" |
| 131 | value={props.model} | 118 | : "truncate", |
| 132 | onValueChange={(value) => handleModelChange(props.lockedProvider!, value)} | 119 | )} |
| 133 | > | 120 | /> |
| 134 | {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( | ||
| 135 | <MenuRadioItem | ||
| 136 | key={`${props.lockedProvider}:${modelOption.slug}`} | ||
| 137 | value={modelOption.slug} | ||
| 138 | onClick={() => setIsMenuOpen(false)} | ||
| 139 | > | ||
| 140 | {modelOption.name} | ||
| 141 | </MenuRadioItem> | ||
| 142 | ))} | ||
| 143 | </MenuRadioGroup> | ||
| 144 | </MenuGroup> | ||
| 145 | ) : ( | ||
| 146 | <> | ||
| 147 | {AVAILABLE_PROVIDER_OPTIONS.map((option) => { | ||
| 148 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | ||
| 149 | const liveProvider = props.providers | ||
| 150 | ? getProviderSnapshot(props.providers, option.value) | ||
| 151 | : undefined; | ||
| 152 | if (liveProvider && liveProvider.status !== "ready") { | ||
| 153 | const unavailableLabel = !liveProvider.enabled | ||
| 154 | ? "Disabled" | ||
| 155 | : !liveProvider.installed | ||
| 156 | ? "Not installed" | ||
| 157 | : "Unavailable"; | ||
| 158 | return ( | ||
| 159 | <MenuItem key={option.value} disabled> | ||
| 160 | <OptionIcon | ||
| 161 | aria-hidden="true" | ||
| 162 | className={cn( | ||
| 163 | "size-4 shrink-0 opacity-80", | ||
| 164 | providerIconClassName(option.value, "text-muted-foreground/85"), | ||
| 165 | )} | ||
| 166 | /> | ||
| 167 | <span>{option.label}</span> | ||
| 168 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | ||
| 169 | {unavailableLabel} | ||
| 170 | </span> | ||
| 171 | </MenuItem> | ||
| 172 | ); | ||
| 173 | } | 121 | } |
| 174 | return ( | 122 | > |
| 175 | <MenuSub key={option.value}> | 123 | {triggerSubtitle ? ( |
| 176 | <MenuSubTrigger> | 124 | <> |
| 177 | <OptionIcon | 125 | <span className="min-w-0 truncate">{triggerSubtitle}</span> |
| 178 | aria-hidden="true" | 126 | <span aria-hidden="true" className="shrink-0 opacity-60"> |
| 179 | className={cn( | 127 | · |
| 180 | "size-4 shrink-0", | ||
| 181 | providerIconClassName(option.value, "text-muted-foreground/85"), | ||
| 182 | )} | ||
| 183 | /> | ||
| 184 | {option.label} | ||
| 185 | </MenuSubTrigger> | ||
| 186 | <MenuSubPopup className="[--available-height:min(24rem,70vh)]" sideOffset={4}> | ||
| 187 | <MenuGroup> | ||
| 188 | <MenuRadioGroup | ||
| 189 | value={props.provider === option.value ? props.model : ""} | ||
| 190 | onValueChange={(value) => handleModelChange(option.value, value)} | ||
| 191 | > | ||
| 192 | {props.modelOptionsByProvider[option.value].map((modelOption) => ( | ||
| 193 | <MenuRadioItem | ||
| 194 | key={`${option.value}:${modelOption.slug}`} | ||
| 195 | value={modelOption.slug} | ||
| 196 | onClick={() => setIsMenuOpen(false)} | ||
| 197 | > | ||
| 198 | {modelOption.name} | ||
| 199 | </MenuRadioItem> | ||
| 200 | ))} | ||
| 201 | </MenuRadioGroup> | ||
| 202 | </MenuGroup> | ||
| 203 | </MenuSubPopup> | ||
| 204 | </MenuSub> | ||
| 205 | ); | ||
| 206 | })} | ||
| 207 | {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && <MenuDivider />} | ||
| 208 | {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { | ||
| 209 | const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; | ||
| 210 | return ( | ||
| 211 | <MenuItem key={option.value} disabled> | ||
| 212 | <OptionIcon | ||
| 213 | aria-hidden="true" | ||
| 214 | className="size-4 shrink-0 text-muted-foreground/85 opacity-80" | ||
| 215 | /> | ||
| 216 | <span>{option.label}</span> | ||
| 217 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | ||
| 218 | Coming soon | ||
| 219 | </span> | ||
| 220 | </MenuItem> | ||
| 221 | ); | ||
| 222 | })} | ||
| 223 | {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && <MenuDivider />} | ||
| 224 | {COMING_SOON_PROVIDER_OPTIONS.map((option) => { | ||
| 225 | const OptionIcon = option.icon; | ||
| 226 | return ( | ||
| 227 | <MenuItem key={option.id} disabled> | ||
| 228 | <OptionIcon aria-hidden="true" className="size-4 shrink-0 opacity-80" /> | ||
| 229 | <span>{option.label}</span> | ||
| 230 | <span className="ms-auto text-[11px] text-muted-foreground/80 uppercase tracking-[0.08em]"> | ||
| 231 | Coming soon | ||
| 232 | </span> | 128 | </span> |
| 233 | </MenuItem> | 129 | <span className="min-w-0 truncate">{triggerTitle}</span> |
| 234 | ); | 130 | </> |
| 235 | })} | 131 | ) : ( |
| 236 | </> | 132 | triggerTitle |
| 237 | )} | 133 | )} |
| 238 | </MenuPopup> | 134 | </TooltipTrigger> |
| 239 | </Menu> | 135 | <TooltipPopup side="top">{triggerLabel}</TooltipPopup> |
| 136 | </Tooltip> | ||
| 137 | <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" /> | ||
| 138 | </span> | ||
| 139 | </PopoverTrigger> | ||
| 140 | <PopoverPopup | ||
| 141 | align="start" | ||
| 142 | className="border-0 bg-transparent p-0 shadow-none before:hidden [--viewport-inline-padding:0] *:data-[slot=popover-viewport]:p-0" | ||
| 143 | > | ||
| 144 | <ModelPickerContent | ||
| 145 | provider={props.provider} | ||
| 146 | model={props.model} | ||
| 147 | lockedProvider={props.lockedProvider} | ||
| 148 | {...(props.providers && { providers: props.providers })} | ||
| 149 | {...(props.keybindings ? { keybindings: props.keybindings } : {})} | ||
| 150 | modelOptionsByProvider={props.modelOptionsByProvider} | ||
| 151 | terminalOpen={props.terminalOpen ?? false} | ||
| 152 | onRequestClose={() => setIsMenuOpen(false)} | ||
| 153 | onProviderModelChange={handleProviderModelChange} | ||
| 154 | /> | ||
| 155 | </PopoverPopup> | ||
| 156 | </Popover> | ||
| 240 | ); | 157 | ); |
| 241 | }); | 158 | }); |
apps/web/src/components/chat/modelPickerModelHighlights.ts created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import type { ProviderKind } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * Model slugs that show a gold "NEW" chip in the model picker list. | ||
| 5 | * Add entries as `provider:slug` when you want to highlight freshly shipped models. | ||
| 6 | */ | ||
| 7 | const NEW_MODEL_KEYS = new Set<string>([ | ||
| 8 | // Example: "claudeAgent:claude-opus-4-7", | ||
| 9 | ]); | ||
| 10 | |||
| 11 | export function isModelPickerNewModel(provider: ProviderKind, slug: string): boolean { | ||
| 12 | return NEW_MODEL_KEYS.has(`${provider}:${slug}`); | ||
| 13 | } | ||
apps/web/src/components/chat/modelPickerSearch.test.ts created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; | ||
| 4 | |||
| 5 | describe("buildModelPickerSearchText", () => { | ||
| 6 | it("builds provider-agnostic search text from generic fields", () => { | ||
| 7 | expect( | ||
| 8 | buildModelPickerSearchText({ | ||
| 9 | provider: "opencode", | ||
| 10 | name: "Claude Opus 4.7", | ||
| 11 | subProvider: "GitHub Copilot", | ||
| 12 | }), | ||
| 13 | ).toBe("claude opus 4.7 github copilot opencode opencode"); | ||
| 14 | }); | ||
| 15 | }); | ||
| 16 | |||
| 17 | describe("scoreModelPickerSearch", () => { | ||
| 18 | it("matches typo-tolerant multi-token queries", () => { | ||
| 19 | expect( | ||
| 20 | scoreModelPickerSearch( | ||
| 21 | { | ||
| 22 | provider: "opencode", | ||
| 23 | name: "Claude Opus 4.7", | ||
| 24 | subProvider: "GitHub Copilot", | ||
| 25 | }, | ||
| 26 | "coplt op", | ||
| 27 | ), | ||
| 28 | ).not.toBeNull(); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("rejects results when any query token does not match", () => { | ||
| 32 | expect( | ||
| 33 | scoreModelPickerSearch( | ||
| 34 | { | ||
| 35 | provider: "codex", | ||
| 36 | name: "GPT-5 Codex", | ||
| 37 | }, | ||
| 38 | "coplt op", | ||
| 39 | ), | ||
| 40 | ).toBeNull(); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("ranks exact token matches ahead of fuzzier matches", () => { | ||
| 44 | const exactScore = scoreModelPickerSearch( | ||
| 45 | { | ||
| 46 | provider: "opencode", | ||
| 47 | name: "Claude Opus 4.7", | ||
| 48 | subProvider: "GitHub Copilot", | ||
| 49 | }, | ||
| 50 | "copilot opus", | ||
| 51 | ); | ||
| 52 | const fuzzyScore = scoreModelPickerSearch( | ||
| 53 | { | ||
| 54 | provider: "opencode", | ||
| 55 | name: "Claude Opus 4.7", | ||
| 56 | subProvider: "GitHub Copilot", | ||
| 57 | }, | ||
| 58 | "coplt op", | ||
| 59 | ); | ||
| 60 | |||
| 61 | expect(exactScore).not.toBeNull(); | ||
| 62 | expect(fuzzyScore).not.toBeNull(); | ||
| 63 | expect(exactScore!).toBeLessThan(fuzzyScore!); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("gives favorite models a strong enough ranking boost for partial queries", () => { | ||
| 67 | const favoriteScore = scoreModelPickerSearch( | ||
| 68 | { | ||
| 69 | provider: "claudeAgent", | ||
| 70 | name: "Claude Opus 4.7", | ||
| 71 | isFavorite: true, | ||
| 72 | }, | ||
| 73 | "opu", | ||
| 74 | ); | ||
| 75 | const nonFavoriteScore = scoreModelPickerSearch( | ||
| 76 | { | ||
| 77 | provider: "cursor", | ||
| 78 | name: "Opus 4.5", | ||
| 79 | }, | ||
| 80 | "opu", | ||
| 81 | ); | ||
| 82 | |||
| 83 | expect(favoriteScore).not.toBeNull(); | ||
| 84 | expect(nonFavoriteScore).not.toBeNull(); | ||
| 85 | expect(favoriteScore!).toBeLessThan(nonFavoriteScore!); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("does not let the favorite boost outrank clearly better textual matches", () => { | ||
| 89 | const favoriteScore = scoreModelPickerSearch( | ||
| 90 | { | ||
| 91 | provider: "claudeAgent", | ||
| 92 | name: "Claude Opus 4.7", | ||
| 93 | isFavorite: true, | ||
| 94 | }, | ||
| 95 | "opus 4.7", | ||
| 96 | ); | ||
| 97 | const nonFavoriteExactScore = scoreModelPickerSearch( | ||
| 98 | { | ||
| 99 | provider: "cursor", | ||
| 100 | name: "Opus 4.7", | ||
| 101 | }, | ||
| 102 | "opus 4.7", | ||
| 103 | ); | ||
| 104 | |||
| 105 | expect(favoriteScore).not.toBeNull(); | ||
| 106 | expect(nonFavoriteExactScore).not.toBeNull(); | ||
| 107 | expect(nonFavoriteExactScore!).toBeLessThan(favoriteScore!); | ||
| 108 | }); | ||
| 109 | }); | ||
apps/web/src/components/chat/modelPickerSearch.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | ||
| 2 | import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking"; | ||
| 3 | |||
| 4 | type ModelPickerSearchableModel = { | ||
| 5 | provider: ProviderKind; | ||
| 6 | name: string; | ||
| 7 | shortName?: string; | ||
| 8 | subProvider?: string; | ||
| 9 | isFavorite?: boolean; | ||
| 10 | }; | ||
| 11 | |||
| 12 | const MODEL_PICKER_FAVORITE_SCORE_BOOST = 24; | ||
| 13 | |||
| 14 | function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] { | ||
| 15 | return [ | ||
| 16 | normalizeSearchQuery(model.name), | ||
| 17 | ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []), | ||
| 18 | ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []), | ||
| 19 | normalizeSearchQuery(model.provider), | ||
| 20 | normalizeSearchQuery(PROVIDER_DISPLAY_NAMES[model.provider]), | ||
| 21 | buildModelPickerSearchText(model), | ||
| 22 | ]; | ||
| 23 | } | ||
| 24 | |||
| 25 | function scoreModelPickerSearchToken( | ||
| 26 | field: string, | ||
| 27 | token: string, | ||
| 28 | fieldBase: number, | ||
| 29 | ): number | null { | ||
| 30 | return scoreQueryMatch({ | ||
| 31 | value: field, | ||
| 32 | query: token, | ||
| 33 | exactBase: fieldBase, | ||
| 34 | prefixBase: fieldBase + 2, | ||
| 35 | boundaryBase: fieldBase + 4, | ||
| 36 | includesBase: fieldBase + 6, | ||
| 37 | ...(token.length >= 3 ? { fuzzyBase: fieldBase + 100 } : {}), | ||
| 38 | }); | ||
| 39 | } | ||
| 40 | |||
| 41 | export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string { | ||
| 42 | return normalizeSearchQuery( | ||
| 43 | [ | ||
| 44 | model.name, | ||
| 45 | model.shortName, | ||
| 46 | model.subProvider, | ||
| 47 | model.provider, | ||
| 48 | PROVIDER_DISPLAY_NAMES[model.provider], | ||
| 49 | ] | ||
| 50 | .filter((value): value is string => typeof value === "string" && value.length > 0) | ||
| 51 | .join(" "), | ||
| 52 | ); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function scoreModelPickerSearch( | ||
| 56 | model: ModelPickerSearchableModel, | ||
| 57 | query: string, | ||
| 58 | ): number | null { | ||
| 59 | const tokens = normalizeSearchQuery(query) | ||
| 60 | .split(/\s+/u) | ||
| 61 | .filter((token) => token.length > 0); | ||
| 62 | |||
| 63 | if (tokens.length === 0) { | ||
| 64 | return 0; | ||
| 65 | } | ||
| 66 | |||
| 67 | const fields = getModelPickerSearchFields(model); | ||
| 68 | let score = 0; | ||
| 69 | |||
| 70 | for (const token of tokens) { | ||
| 71 | const tokenScores = fields | ||
| 72 | .map((field, index) => scoreModelPickerSearchToken(field, token, index * 10)) | ||
| 73 | .filter((fieldScore): fieldScore is number => fieldScore !== null); | ||
| 74 | |||
| 75 | if (tokenScores.length === 0) { | ||
| 76 | return null; | ||
| 77 | } | ||
| 78 | |||
| 79 | score += Math.min(...tokenScores); | ||
| 80 | } | ||
| 81 | |||
| 82 | return model.isFavorite ? score - MODEL_PICKER_FAVORITE_SCORE_BOOST : score; | ||
| 83 | } | ||
apps/web/src/components/chat/providerIconUtils.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | ||
| 2 | import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | ||
| 3 | import { PROVIDER_OPTIONS } from "../../session-logic"; | ||
| 4 | |||
| 5 | export const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = { | ||
| 6 | codex: OpenAI, | ||
| 7 | claudeAgent: ClaudeAI, | ||
| 8 | opencode: OpenCodeIcon, | ||
| 9 | cursor: CursorIcon, | ||
| 10 | }; | ||
| 11 | |||
| 12 | function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { | ||
| 13 | value: ProviderKind; | ||
| 14 | label: string; | ||
| 15 | available: true; | ||
| 16 | pickerSidebarBadge?: "new" | "soon"; | ||
| 17 | } { | ||
| 18 | return option.available; | ||
| 19 | } | ||
| 20 | |||
| 21 | export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); | ||
| 22 | |||
| 23 | export type ModelEsque = { | ||
| 24 | slug: string; | ||
| 25 | name: string; | ||
| 26 | shortName?: string | undefined; | ||
| 27 | subProvider?: string | undefined; | ||
| 28 | }; | ||
| 29 | |||
| 30 | export function getProviderLabel(provider: ProviderKind, model: ModelEsque): string { | ||
| 31 | const providerLabel = PROVIDER_DISPLAY_NAMES[provider]; | ||
| 32 | return model.subProvider ? `${providerLabel} · ${model.subProvider}` : providerLabel; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function getDisplayModelName( | ||
| 36 | model: ModelEsque, | ||
| 37 | options?: { preferShortName?: boolean }, | ||
| 38 | ): string { | ||
| 39 | if (options?.preferShortName && model.shortName) { | ||
| 40 | return model.shortName; | ||
| 41 | } | ||
| 42 | return model.name; | ||
| 43 | } | ||
| 44 | |||
| 45 | export function getTriggerDisplayModelName(model: ModelEsque): string { | ||
| 46 | return getDisplayModelName(model, { preferShortName: true }); | ||
| 47 | } | ||
| 48 | |||
| 49 | export function getTriggerDisplayModelLabel(model: ModelEsque): string { | ||
| 50 | const title = getTriggerDisplayModelName(model); | ||
| 51 | return model.subProvider ? `${model.subProvider} · ${title}` : title; | ||
| 52 | } | ||
apps/web/src/components/ui/autocomplete.tsx+1-1| ... | @@ -193,7 +193,7 @@ function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Pr | ... | @@ -193,7 +193,7 @@ function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Pr |
| 193 | return ( | 193 | return ( |
| 194 | <ScrollArea scrollbarGutter scrollFade> | 194 | <ScrollArea scrollbarGutter scrollFade> |
| 195 | <AutocompletePrimitive.List | 195 | <AutocompletePrimitive.List |
| 196 | className={cn("not-empty:scroll-py-1 not-empty:p-1 in-data-has-overflow-y:pe-3", className)} | 196 | className={cn("not-empty:scroll-py-1 not-empty:p-1", className)} |
| 197 | data-slot="autocomplete-list" | 197 | data-slot="autocomplete-list" |
| 198 | {...props} | 198 | {...props} |
| 199 | /> | 199 | /> |
apps/web/src/components/ui/combobox.tsx+11-5| ... | @@ -186,10 +186,12 @@ function ComboboxPopup({ | ... | @@ -186,10 +186,12 @@ function ComboboxPopup({ |
| 186 | 186 | ||
| 187 | function ComboboxItem({ | 187 | function ComboboxItem({ |
| 188 | className, | 188 | className, |
| 189 | contentClassName, | ||
| 189 | children, | 190 | children, |
| 190 | hideIndicator = false, | 191 | hideIndicator = false, |
| 191 | ...props | 192 | ...props |
| 192 | }: ComboboxPrimitive.Item.Props & { | 193 | }: ComboboxPrimitive.Item.Props & { |
| 194 | contentClassName?: string; | ||
| 193 | hideIndicator?: boolean; | 195 | hideIndicator?: boolean; |
| 194 | }) { | 196 | }) { |
| 195 | return ( | 197 | return ( |
| ... | @@ -204,7 +206,14 @@ function ComboboxItem({ | ... | @@ -204,7 +206,14 @@ function ComboboxItem({ |
| 204 | <ComboboxPrimitive.ItemIndicator className={cn("col-start-1", hideIndicator && "hidden")}> | 206 | <ComboboxPrimitive.ItemIndicator className={cn("col-start-1", hideIndicator && "hidden")}> |
| 205 | <CheckIcon /> | 207 | <CheckIcon /> |
| 206 | </ComboboxPrimitive.ItemIndicator> | 208 | </ComboboxPrimitive.ItemIndicator> |
| 207 | <div className={hideIndicator ? "col-start-1 col-span-full" : "col-start-2"}>{children}</div> | 209 | <div |
| 210 | className={cn( | ||
| 211 | hideIndicator ? "col-start-1 col-span-full" : "col-start-2", | ||
| 212 | contentClassName, | ||
| 213 | )} | ||
| 214 | > | ||
| 215 | {children} | ||
| 216 | </div> | ||
| 208 | </ComboboxPrimitive.Item> | 217 | </ComboboxPrimitive.Item> |
| 209 | ); | 218 | ); |
| 210 | } | 219 | } |
| ... | @@ -264,10 +273,7 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { | ... | @@ -264,10 +273,7 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { |
| 264 | return ( | 273 | return ( |
| 265 | <ScrollArea scrollbarGutter scrollFade> | 274 | <ScrollArea scrollbarGutter scrollFade> |
| 266 | <ComboboxPrimitive.List | 275 | <ComboboxPrimitive.List |
| 267 | className={cn( | 276 | className={cn("not-empty:scroll-py-1 not-empty:px-1 not-empty:py-1", className)} |
| 268 | "not-empty:scroll-py-1 not-empty:px-1 not-empty:py-1 in-data-has-overflow-y:pe-3", | ||
| 269 | className, | ||
| 270 | )} | ||
| 271 | data-slot="combobox-list" | 277 | data-slot="combobox-list" |
| 272 | {...props} | 278 | {...props} |
| 273 | /> | 279 | /> |
apps/web/src/components/ui/scroll-area.tsx+5-2| ... | @@ -17,13 +17,16 @@ function ScrollArea({ | ... | @@ -17,13 +17,16 @@ function ScrollArea({ |
| 17 | hideScrollbars?: boolean; | 17 | hideScrollbars?: boolean; |
| 18 | }) { | 18 | }) { |
| 19 | return ( | 19 | return ( |
| 20 | <ScrollAreaPrimitive.Root className={cn("size-full min-h-0", className)} {...props}> | 20 | <ScrollAreaPrimitive.Root |
| 21 | className={cn("relative size-full min-h-0 overflow-hidden rounded-[inherit]", className)} | ||
| 22 | {...props} | ||
| 23 | > | ||
| 21 | <ScrollAreaPrimitive.Viewport | 24 | <ScrollAreaPrimitive.Viewport |
| 22 | className={cn( | 25 | className={cn( |
| 23 | "h-full overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", | 26 | "h-full overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", |
| 24 | scrollFade && | 27 | scrollFade && |
| 25 | "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", | 28 | "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", |
| 26 | scrollbarGutter && "data-has-overflow-y:pe-2.5 data-has-overflow-x:pb-2.5", | 29 | scrollbarGutter && "[scrollbar-gutter:stable]", |
| 27 | hideScrollbars && | 30 | hideScrollbars && |
| 28 | "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", | 31 | "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", |
| 29 | )} | 32 | )} |
apps/web/src/composer-logic.test.ts+11-4| ... | @@ -36,18 +36,25 @@ describe("detectComposerTrigger", () => { | ... | @@ -36,18 +36,25 @@ describe("detectComposerTrigger", () => { |
| 36 | }); | 36 | }); |
| 37 | }); | 37 | }); |
| 38 | 38 | ||
| 39 | it("detects slash model query after /model", () => { | 39 | it("keeps /model as a slash command item", () => { |
| 40 | const text = "/model spark"; | 40 | const text = "/model"; |
| 41 | const trigger = detectComposerTrigger(text, text.length); | 41 | const trigger = detectComposerTrigger(text, text.length); |
| 42 | 42 | ||
| 43 | expect(trigger).toEqual({ | 43 | expect(trigger).toEqual({ |
| 44 | kind: "slash-model", | 44 | kind: "slash-command", |
| 45 | query: "spark", | 45 | query: "model", |
| 46 | rangeStart: 0, | 46 | rangeStart: 0, |
| 47 | rangeEnd: text.length, | 47 | rangeEnd: text.length, |
| 48 | }); | 48 | }); |
| 49 | }); | 49 | }); |
| 50 | 50 | ||
| 51 | it("does not keep a subcommand trigger active after /model arguments", () => { | ||
| 52 | const text = "/model spark"; | ||
| 53 | const trigger = detectComposerTrigger(text, text.length); | ||
| 54 | |||
| 55 | expect(trigger).toBeNull(); | ||
| 56 | }); | ||
| 57 | |||
| 51 | it("detects non-model slash commands while typing", () => { | 58 | it("detects non-model slash commands while typing", () => { |
| 52 | const text = "/pl"; | 59 | const text = "/pl"; |
| 53 | const trigger = detectComposerTrigger(text, text.length); | 60 | const trigger = detectComposerTrigger(text, text.length); |
apps/web/src/composer-logic.ts+1-19| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; | 1 | import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; |
| 2 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; | 2 | import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; |
| 3 | 3 | ||
| 4 | export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill"; | 4 | export type ComposerTriggerKind = "path" | "slash-command" | "skill"; |
| 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; | 5 | export type ComposerSlashCommand = "model" | "plan" | "default"; |
| 6 | 6 | ||
| 7 | export interface ComposerTrigger { | 7 | export interface ComposerTrigger { |
| ... | @@ -224,14 +224,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos | ... | @@ -224,14 +224,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 224 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); | 224 | const commandMatch = /^\/(\S*)$/.exec(linePrefix); |
| 225 | if (commandMatch) { | 225 | if (commandMatch) { |
| 226 | const commandQuery = commandMatch[1] ?? ""; | 226 | const commandQuery = commandMatch[1] ?? ""; |
| 227 | if (commandQuery.toLowerCase() === "model") { | ||
| 228 | return { | ||
| 229 | kind: "slash-model", | ||
| 230 | query: "", | ||
| 231 | rangeStart: lineStart, | ||
| 232 | rangeEnd: cursor, | ||
| 233 | }; | ||
| 234 | } | ||
| 235 | return { | 227 | return { |
| 236 | kind: "slash-command", | 228 | kind: "slash-command", |
| 237 | query: commandQuery, | 229 | query: commandQuery, |
| ... | @@ -239,16 +231,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos | ... | @@ -239,16 +231,6 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos |
| 239 | rangeEnd: cursor, | 231 | rangeEnd: cursor, |
| 240 | }; | 232 | }; |
| 241 | } | 233 | } |
| 242 | |||
| 243 | const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); | ||
| 244 | if (modelMatch) { | ||
| 245 | return { | ||
| 246 | kind: "slash-model", | ||
| 247 | query: (modelMatch[1] ?? "").trim(), | ||
| 248 | rangeStart: lineStart, | ||
| 249 | rangeEnd: cursor, | ||
| 250 | }; | ||
| 251 | } | ||
| 252 | } | 234 | } |
| 253 | 235 | ||
| 254 | const tokenStart = tokenStartForCursor(text, cursor); | 236 | const tokenStart = tokenStartForCursor(text, cursor); |
apps/web/src/hooks/useSettings.ts+4-3| ... | @@ -10,8 +10,9 @@ | ... | @@ -10,8 +10,9 @@ |
| 10 | * store. | 10 | * store. |
| 11 | */ | 11 | */ |
| 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; | 12 | import { useCallback, useMemo, useSyncExternalStore } from "react"; |
| 13 | import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; | 13 | import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; |
| 14 | import { | 14 | import { |
| 15 | type ClientSettingsPatch, | ||
| 15 | type ClientSettings, | 16 | type ClientSettings, |
| 16 | DEFAULT_CLIENT_SETTINGS, | 17 | DEFAULT_CLIENT_SETTINGS, |
| 17 | DEFAULT_UNIFIED_SETTINGS, | 18 | DEFAULT_UNIFIED_SETTINGS, |
| ... | @@ -98,7 +99,7 @@ const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)) | ... | @@ -98,7 +99,7 @@ const SERVER_SETTINGS_KEYS = new Set<string>(Struct.keys(ServerSettings.fields)) |
| 98 | 99 | ||
| 99 | function splitPatch(patch: Partial<UnifiedSettings>): { | 100 | function splitPatch(patch: Partial<UnifiedSettings>): { |
| 100 | serverPatch: ServerSettingsPatch; | 101 | serverPatch: ServerSettingsPatch; |
| 101 | clientPatch: Partial<ClientSettings>; | 102 | clientPatch: ClientSettingsPatch; |
| 102 | } { | 103 | } { |
| 103 | const serverPatch: Record<string, unknown> = {}; | 104 | const serverPatch: Record<string, unknown> = {}; |
| 104 | const clientPatch: Record<string, unknown> = {}; | 105 | const clientPatch: Record<string, unknown> = {}; |
| ... | @@ -111,7 +112,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { | ... | @@ -111,7 +112,7 @@ function splitPatch(patch: Partial<UnifiedSettings>): { |
| 111 | } | 112 | } |
| 112 | return { | 113 | return { |
| 113 | serverPatch: serverPatch as ServerSettingsPatch, | 114 | serverPatch: serverPatch as ServerSettingsPatch, |
| 114 | clientPatch: clientPatch as Partial<ClientSettings>, | 115 | clientPatch: clientPatch as ClientSettingsPatch, |
| 115 | }; | 116 | }; |
| 116 | } | 117 | } |
| 117 | 118 |
apps/web/src/index.css+22| ... | @@ -534,3 +534,25 @@ label:has(> select#reasoning-effort) select { | ... | @@ -534,3 +534,25 @@ label:has(> select#reasoning-effort) select { |
| 534 | -webkit-background-clip: text; | 534 | -webkit-background-clip: text; |
| 535 | animation: ultrathink-rainbow 10s linear infinite; | 535 | animation: ultrathink-rainbow 10s linear infinite; |
| 536 | } | 536 | } |
| 537 | |||
| 538 | /* Thin scrollbar for model picker */ | ||
| 539 | .model-picker-list::-webkit-scrollbar { | ||
| 540 | width: 4px; | ||
| 541 | } | ||
| 542 | |||
| 543 | .model-picker-list::-webkit-scrollbar-thumb { | ||
| 544 | background: rgba(0, 0, 0, 0.1); | ||
| 545 | border-radius: 2px; | ||
| 546 | } | ||
| 547 | |||
| 548 | .model-picker-list::-webkit-scrollbar-thumb:hover { | ||
| 549 | background: rgba(0, 0, 0, 0.2); | ||
| 550 | } | ||
| 551 | |||
| 552 | .dark .model-picker-list::-webkit-scrollbar-thumb { | ||
| 553 | background: rgba(255, 255, 255, 0.08); | ||
| 554 | } | ||
| 555 | |||
| 556 | .dark .model-picker-list::-webkit-scrollbar-thumb:hover { | ||
| 557 | background: rgba(255, 255, 255, 0.15); | ||
| 558 | } |
apps/web/src/keybindings.test.ts+60| ... | @@ -11,6 +11,8 @@ import { | ... | @@ -11,6 +11,8 @@ import { |
| 11 | isChatNewShortcut, | 11 | isChatNewShortcut, |
| 12 | isChatNewLocalShortcut, | 12 | isChatNewLocalShortcut, |
| 13 | isDiffToggleShortcut, | 13 | isDiffToggleShortcut, |
| 14 | modelPickerJumpCommandForIndex, | ||
| 15 | modelPickerJumpIndexFromCommand, | ||
| 14 | isOpenFavoriteEditorShortcut, | 16 | isOpenFavoriteEditorShortcut, |
| 15 | isTerminalClearShortcut, | 17 | isTerminalClearShortcut, |
| 16 | isTerminalCloseShortcut, | 18 | isTerminalCloseShortcut, |
| ... | @@ -18,6 +20,7 @@ import { | ... | @@ -18,6 +20,7 @@ import { |
| 18 | isTerminalSplitShortcut, | 20 | isTerminalSplitShortcut, |
| 19 | isTerminalToggleShortcut, | 21 | isTerminalToggleShortcut, |
| 20 | resolveShortcutCommand, | 22 | resolveShortcutCommand, |
| 23 | shouldShowModelPickerJumpHints, | ||
| 21 | shouldShowThreadJumpHints, | 24 | shouldShowThreadJumpHints, |
| 22 | shortcutLabelForCommand, | 25 | shortcutLabelForCommand, |
| 23 | terminalDeleteShortcutData, | 26 | terminalDeleteShortcutData, |
| ... | @@ -107,6 +110,11 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -107,6 +110,11 @@ const DEFAULT_BINDINGS = compile([ |
| 107 | command: "commandPalette.toggle", | 110 | command: "commandPalette.toggle", |
| 108 | whenAst: whenNot(whenIdentifier("terminalFocus")), | 111 | whenAst: whenNot(whenIdentifier("terminalFocus")), |
| 109 | }, | 112 | }, |
| 113 | { | ||
| 114 | shortcut: modShortcut("m", { shiftKey: true }), | ||
| 115 | command: "modelPicker.toggle", | ||
| 116 | whenAst: whenNot(whenIdentifier("terminalFocus")), | ||
| 117 | }, | ||
| 110 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, | 118 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 111 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, | 119 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, |
| 112 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, | 120 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| ... | @@ -115,6 +123,21 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -115,6 +123,21 @@ const DEFAULT_BINDINGS = compile([ |
| 115 | { shortcut: modShortcut("1"), command: "thread.jump.1" }, | 123 | { shortcut: modShortcut("1"), command: "thread.jump.1" }, |
| 116 | { shortcut: modShortcut("2"), command: "thread.jump.2" }, | 124 | { shortcut: modShortcut("2"), command: "thread.jump.2" }, |
| 117 | { shortcut: modShortcut("3"), command: "thread.jump.3" }, | 125 | { shortcut: modShortcut("3"), command: "thread.jump.3" }, |
| 126 | { | ||
| 127 | shortcut: modShortcut("1"), | ||
| 128 | command: "modelPicker.jump.1", | ||
| 129 | whenAst: whenIdentifier("modelPickerOpen"), | ||
| 130 | }, | ||
| 131 | { | ||
| 132 | shortcut: modShortcut("2"), | ||
| 133 | command: "modelPicker.jump.2", | ||
| 134 | whenAst: whenIdentifier("modelPickerOpen"), | ||
| 135 | }, | ||
| 136 | { | ||
| 137 | shortcut: modShortcut("3"), | ||
| 138 | command: "modelPicker.jump.3", | ||
| 139 | whenAst: whenIdentifier("modelPickerOpen"), | ||
| 140 | }, | ||
| 118 | ]); | 141 | ]); |
| 119 | 142 | ||
| 120 | describe("isTerminalToggleShortcut", () => { | 143 | describe("isTerminalToggleShortcut", () => { |
| ... | @@ -268,6 +291,10 @@ describe("shortcutLabelForCommand", () => { | ... | @@ -268,6 +291,10 @@ describe("shortcutLabelForCommand", () => { |
| 268 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), | 291 | shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), |
| 269 | "⌘K", | 292 | "⌘K", |
| 270 | ); | 293 | ); |
| 294 | assert.strictEqual( | ||
| 295 | shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), | ||
| 296 | "Ctrl+Shift+M", | ||
| 297 | ); | ||
| 271 | assert.strictEqual( | 298 | assert.strictEqual( |
| 272 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), | 299 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), |
| 273 | "Ctrl+O", | 300 | "Ctrl+O", |
| ... | @@ -280,6 +307,13 @@ describe("shortcutLabelForCommand", () => { | ... | @@ -280,6 +307,13 @@ describe("shortcutLabelForCommand", () => { |
| 280 | shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), | 307 | shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), |
| 281 | "Ctrl+Shift+[", | 308 | "Ctrl+Shift+[", |
| 282 | ); | 309 | ); |
| 310 | assert.strictEqual( | ||
| 311 | shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.jump.3", { | ||
| 312 | platform: "MacIntel", | ||
| 313 | context: { modelPickerOpen: true }, | ||
| 314 | }), | ||
| 315 | "⌘3", | ||
| 316 | ); | ||
| 283 | }); | 317 | }); |
| 284 | 318 | ||
| 285 | it("returns null for commands shadowed by a later conflicting shortcut", () => { | 319 | it("returns null for commands shadowed by a later conflicting shortcut", () => { |
| ... | @@ -361,6 +395,32 @@ describe("thread navigation helpers", () => { | ... | @@ -361,6 +395,32 @@ describe("thread navigation helpers", () => { |
| 361 | }); | 395 | }); |
| 362 | }); | 396 | }); |
| 363 | 397 | ||
| 398 | describe("model picker navigation helpers", () => { | ||
| 399 | it("maps jump commands to visible model indices", () => { | ||
| 400 | assert.strictEqual(modelPickerJumpCommandForIndex(0), "modelPicker.jump.1"); | ||
| 401 | assert.strictEqual(modelPickerJumpCommandForIndex(2), "modelPicker.jump.3"); | ||
| 402 | assert.isNull(modelPickerJumpCommandForIndex(9)); | ||
| 403 | assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.1"), 0); | ||
| 404 | assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); | ||
| 405 | assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); | ||
| 406 | }); | ||
| 407 | |||
| 408 | it("shows jump hints only while the model picker context is active", () => { | ||
| 409 | assert.isFalse( | ||
| 410 | shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { | ||
| 411 | platform: "MacIntel", | ||
| 412 | context: { modelPickerOpen: false }, | ||
| 413 | }), | ||
| 414 | ); | ||
| 415 | assert.isTrue( | ||
| 416 | shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { | ||
| 417 | platform: "MacIntel", | ||
| 418 | context: { modelPickerOpen: true }, | ||
| 419 | }), | ||
| 420 | ); | ||
| 421 | }); | ||
| 422 | }); | ||
| 423 | |||
| 364 | describe("chat/editor shortcuts", () => { | 424 | describe("chat/editor shortcuts", () => { |
| 365 | it("matches chat.new shortcut", () => { | 425 | it("matches chat.new shortcut", () => { |
| 366 | assert.isTrue( | 426 | assert.isTrue( |
apps/web/src/keybindings.ts+58-2| ... | @@ -2,8 +2,10 @@ import { | ... | @@ -2,8 +2,10 @@ import { |
| 2 | type KeybindingCommand, | 2 | type KeybindingCommand, |
| 3 | type KeybindingShortcut, | 3 | type KeybindingShortcut, |
| 4 | type KeybindingWhenNode, | 4 | type KeybindingWhenNode, |
| 5 | MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | ||
| 5 | type ResolvedKeybindingsConfig, | 6 | type ResolvedKeybindingsConfig, |
| 6 | THREAD_JUMP_KEYBINDING_COMMANDS, | 7 | THREAD_JUMP_KEYBINDING_COMMANDS, |
| 8 | type ModelPickerJumpKeybindingCommand, | ||
| 7 | type ThreadJumpKeybindingCommand, | 9 | type ThreadJumpKeybindingCommand, |
| 8 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 9 | import { isMacPlatform } from "./lib/utils"; | 11 | import { isMacPlatform } from "./lib/utils"; |
| ... | @@ -18,6 +20,13 @@ export interface ShortcutEventLike { | ... | @@ -18,6 +20,13 @@ export interface ShortcutEventLike { |
| 18 | altKey: boolean; | 20 | altKey: boolean; |
| 19 | } | 21 | } |
| 20 | 22 | ||
| 23 | export interface ShortcutModifierStateLike { | ||
| 24 | metaKey: boolean; | ||
| 25 | ctrlKey: boolean; | ||
| 26 | shiftKey: boolean; | ||
| 27 | altKey: boolean; | ||
| 28 | } | ||
| 29 | |||
| 21 | export interface ShortcutMatchContext { | 30 | export interface ShortcutMatchContext { |
| 22 | terminalFocus: boolean; | 31 | terminalFocus: boolean; |
| 23 | terminalOpen: boolean; | 32 | terminalOpen: boolean; |
| ... | @@ -71,7 +80,7 @@ function resolveEventKeys(event: ShortcutEventLike): Set<string> { | ... | @@ -71,7 +80,7 @@ function resolveEventKeys(event: ShortcutEventLike): Set<string> { |
| 71 | } | 80 | } |
| 72 | 81 | ||
| 73 | function matchesShortcutModifiers( | 82 | function matchesShortcutModifiers( |
| 74 | event: ShortcutEventLike, | 83 | event: ShortcutModifierStateLike, |
| 75 | shortcut: KeybindingShortcut, | 84 | shortcut: KeybindingShortcut, |
| 76 | platform = navigator.platform, | 85 | platform = navigator.platform, |
| 77 | ): boolean { | 86 | ): boolean { |
| ... | @@ -269,13 +278,60 @@ export function shouldShowThreadJumpHints( | ... | @@ -269,13 +278,60 @@ export function shouldShowThreadJumpHints( |
| 269 | event: ShortcutEventLike, | 278 | event: ShortcutEventLike, |
| 270 | keybindings: ResolvedKeybindingsConfig, | 279 | keybindings: ResolvedKeybindingsConfig, |
| 271 | options?: ShortcutMatchOptions, | 280 | options?: ShortcutMatchOptions, |
| 281 | ): boolean { | ||
| 282 | return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); | ||
| 283 | } | ||
| 284 | |||
| 285 | export function shouldShowThreadJumpHintsForModifiers( | ||
| 286 | modifiers: ShortcutModifierStateLike, | ||
| 287 | keybindings: ResolvedKeybindingsConfig, | ||
| 288 | options?: ShortcutMatchOptions, | ||
| 272 | ): boolean { | 289 | ): boolean { |
| 273 | const platform = resolvePlatform(options); | 290 | const platform = resolvePlatform(options); |
| 274 | 291 | ||
| 275 | for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { | 292 | for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { |
| 276 | const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); | 293 | const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); |
| 277 | if (!shortcut) continue; | 294 | if (!shortcut) continue; |
| 278 | if (matchesShortcutModifiers(event, shortcut, platform)) { | 295 | if (matchesShortcutModifiers(modifiers, shortcut, platform)) { |
| 296 | return true; | ||
| 297 | } | ||
| 298 | } | ||
| 299 | |||
| 300 | return false; | ||
| 301 | } | ||
| 302 | |||
| 303 | export function modelPickerJumpCommandForIndex( | ||
| 304 | index: number, | ||
| 305 | ): ModelPickerJumpKeybindingCommand | null { | ||
| 306 | return MODEL_PICKER_JUMP_KEYBINDING_COMMANDS[index] ?? null; | ||
| 307 | } | ||
| 308 | |||
| 309 | export function modelPickerJumpIndexFromCommand(command: string): number | null { | ||
| 310 | const index = MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.indexOf( | ||
| 311 | command as ModelPickerJumpKeybindingCommand, | ||
| 312 | ); | ||
| 313 | return index === -1 ? null : index; | ||
| 314 | } | ||
| 315 | |||
| 316 | export function shouldShowModelPickerJumpHints( | ||
| 317 | event: ShortcutEventLike, | ||
| 318 | keybindings: ResolvedKeybindingsConfig, | ||
| 319 | options?: ShortcutMatchOptions, | ||
| 320 | ): boolean { | ||
| 321 | return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); | ||
| 322 | } | ||
| 323 | |||
| 324 | export function shouldShowModelPickerJumpHintsForModifiers( | ||
| 325 | modifiers: ShortcutModifierStateLike, | ||
| 326 | keybindings: ResolvedKeybindingsConfig, | ||
| 327 | options?: ShortcutMatchOptions, | ||
| 328 | ): boolean { | ||
| 329 | const platform = resolvePlatform(options); | ||
| 330 | |||
| 331 | for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { | ||
| 332 | const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); | ||
| 333 | if (!shortcut) continue; | ||
| 334 | if (matchesShortcutModifiers(modifiers, shortcut, platform)) { | ||
| 279 | return true; | 335 | return true; |
| 280 | } | 336 | } |
| 281 | } | 337 | } |
apps/web/src/localApi.test.ts+2| ... | @@ -532,6 +532,7 @@ describe("wsApi", () => { | ... | @@ -532,6 +532,7 @@ describe("wsApi", () => { |
| 532 | confirmThreadArchive: true, | 532 | confirmThreadArchive: true, |
| 533 | confirmThreadDelete: false, | 533 | confirmThreadDelete: false, |
| 534 | diffWordWrap: true, | 534 | diffWordWrap: true, |
| 535 | favorites: [], | ||
| 535 | sidebarProjectGroupingMode: "repository_path" as const, | 536 | sidebarProjectGroupingMode: "repository_path" as const, |
| 536 | sidebarProjectGroupingOverrides: { | 537 | sidebarProjectGroupingOverrides: { |
| 537 | "environment-local:/tmp/project": "separate" as const, | 538 | "environment-local:/tmp/project": "separate" as const, |
| ... | @@ -589,6 +590,7 @@ describe("wsApi", () => { | ... | @@ -589,6 +590,7 @@ describe("wsApi", () => { |
| 589 | confirmThreadArchive: true, | 590 | confirmThreadArchive: true, |
| 590 | confirmThreadDelete: false, | 591 | confirmThreadDelete: false, |
| 591 | diffWordWrap: true, | 592 | diffWordWrap: true, |
| 593 | favorites: [], | ||
| 592 | sidebarProjectGroupingMode: "repository_path" as const, | 594 | sidebarProjectGroupingMode: "repository_path" as const, |
| 593 | sidebarProjectGroupingOverrides: { | 595 | sidebarProjectGroupingOverrides: { |
| 594 | "environment-local:/tmp/project": "separate" as const, | 596 | "environment-local:/tmp/project": "separate" as const, |
apps/web/src/modelPickerOpenState.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | import { create } from "zustand"; | ||
| 2 | |||
| 3 | const useModelPickerOpenStore = create<{ | ||
| 4 | open: boolean; | ||
| 5 | setOpen: (open: boolean) => void; | ||
| 6 | }>((set) => ({ | ||
| 7 | open: false, | ||
| 8 | setOpen: (open) => set((current) => (current.open === open ? current : { open })), | ||
| 9 | })); | ||
| 10 | |||
| 11 | export function useModelPickerOpen(): boolean { | ||
| 12 | return useModelPickerOpenStore((store) => store.open); | ||
| 13 | } | ||
| 14 | |||
| 15 | export function setModelPickerOpen(open: boolean): void { | ||
| 16 | useModelPickerOpenStore.getState().setOpen(open); | ||
| 17 | } | ||
apps/web/src/modelSelection.ts+7-2| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | getProviderModels, | 16 | getProviderModels, |
| 17 | resolveSelectableProvider, | 17 | resolveSelectableProvider, |
| 18 | } from "./providerModels"; | 18 | } from "./providerModels"; |
| 19 | import { ModelEsque } from "./components/chat/providerIconUtils"; | ||
| 19 | 20 | ||
| 20 | const MAX_CUSTOM_MODEL_COUNT = 32; | 21 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 21 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | 22 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| ... | @@ -31,6 +32,8 @@ export type ProviderCustomModelConfig = { | ... | @@ -31,6 +32,8 @@ export type ProviderCustomModelConfig = { |
| 31 | export interface AppModelOption { | 32 | export interface AppModelOption { |
| 32 | slug: string; | 33 | slug: string; |
| 33 | name: string; | 34 | name: string; |
| 35 | shortName?: string; | ||
| 36 | subProvider?: string; | ||
| 34 | isCustom: boolean; | 37 | isCustom: boolean; |
| 35 | } | 38 | } |
| 36 | 39 | ||
| ... | @@ -103,9 +106,11 @@ export function getAppModelOptions( | ... | @@ -103,9 +106,11 @@ export function getAppModelOptions( |
| 103 | selectedModel?: string | null, | 106 | selectedModel?: string | null, |
| 104 | ): AppModelOption[] { | 107 | ): AppModelOption[] { |
| 105 | const options: AppModelOption[] = getProviderModels(providers, provider).map( | 108 | const options: AppModelOption[] = getProviderModels(providers, provider).map( |
| 106 | ({ slug, name, isCustom }) => ({ | 109 | ({ slug, name, shortName, subProvider, isCustom }) => ({ |
| 107 | slug, | 110 | slug, |
| 108 | name, | 111 | name, |
| 112 | ...(shortName ? { shortName } : {}), | ||
| 113 | ...(subProvider ? { subProvider } : {}), | ||
| 109 | isCustom, | 114 | isCustom, |
| 110 | }), | 115 | }), |
| 111 | ); | 116 | ); |
| ... | @@ -169,7 +174,7 @@ export function getCustomModelOptionsByProvider( | ... | @@ -169,7 +174,7 @@ export function getCustomModelOptionsByProvider( |
| 169 | providers: ReadonlyArray<ServerProvider>, | 174 | providers: ReadonlyArray<ServerProvider>, |
| 170 | selectedProvider?: ProviderKind | null, | 175 | selectedProvider?: ProviderKind | null, |
| 171 | selectedModel?: string | null, | 176 | selectedModel?: string | null, |
| 172 | ): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> { | 177 | ): Record<ProviderKind, ReadonlyArray<ModelEsque>> { |
| 173 | return { | 178 | return { |
| 174 | codex: getAppModelOptions( | 179 | codex: getAppModelOptions( |
| 175 | settings, | 180 | settings, |
apps/web/src/session-logic.ts+4-2| ... | @@ -28,11 +28,13 @@ export const PROVIDER_OPTIONS: Array<{ | ... | @@ -28,11 +28,13 @@ export const PROVIDER_OPTIONS: Array<{ |
| 28 | value: ProviderPickerKind; | 28 | value: ProviderPickerKind; |
| 29 | label: string; | 29 | label: string; |
| 30 | available: boolean; | 30 | available: boolean; |
| 31 | /** Shown on the model picker sidebar when relevant */ | ||
| 32 | pickerSidebarBadge?: "new" | "soon"; | ||
| 31 | }> = [ | 33 | }> = [ |
| 32 | { value: "codex", label: "Codex", available: true }, | 34 | { value: "codex", label: "Codex", available: true }, |
| 33 | { value: "claudeAgent", label: "Claude", available: true }, | 35 | { value: "claudeAgent", label: "Claude", available: true }, |
| 34 | { value: "opencode", label: "OpenCode", available: true }, | 36 | { value: "opencode", label: "OpenCode", available: true, pickerSidebarBadge: "new" }, |
| 35 | { value: "cursor", label: "Cursor", available: true }, | 37 | { value: "cursor", label: "Cursor", available: true, pickerSidebarBadge: "new" }, |
| 36 | ]; | 38 | ]; |
| 37 | 39 | ||
| 38 | export interface WorkLogEntry { | 40 | export interface WorkLogEntry { |
apps/web/src/shortcutModifierState.test.ts created+146| ... | @@ -0,0 +1,146 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | areShortcutModifierStatesEqual, | ||
| 5 | clearShortcutModifierState, | ||
| 6 | readShortcutModifierState, | ||
| 7 | setShortcutModifierState, | ||
| 8 | syncShortcutModifierStateFromKeyboardEvent, | ||
| 9 | } from "./shortcutModifierState"; | ||
| 10 | |||
| 11 | function keyboardEventLike(type: "keydown" | "keyup", init: Partial<KeyboardEvent>): KeyboardEvent { | ||
| 12 | return { | ||
| 13 | type, | ||
| 14 | key: "", | ||
| 15 | metaKey: false, | ||
| 16 | ctrlKey: false, | ||
| 17 | altKey: false, | ||
| 18 | shiftKey: false, | ||
| 19 | ...init, | ||
| 20 | } as KeyboardEvent; | ||
| 21 | } | ||
| 22 | |||
| 23 | describe("shortcutModifierState", () => { | ||
| 24 | it("compares modifier states by value", () => { | ||
| 25 | expect( | ||
| 26 | areShortcutModifierStatesEqual( | ||
| 27 | { metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, | ||
| 28 | { metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, | ||
| 29 | ), | ||
| 30 | ).toBe(true); | ||
| 31 | expect( | ||
| 32 | areShortcutModifierStatesEqual( | ||
| 33 | { metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, | ||
| 34 | { metaKey: false, ctrlKey: false, altKey: false, shiftKey: true }, | ||
| 35 | ), | ||
| 36 | ).toBe(false); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("preserves the current store object when modifier values do not change", () => { | ||
| 40 | clearShortcutModifierState(); | ||
| 41 | |||
| 42 | const initialState = readShortcutModifierState(); | ||
| 43 | setShortcutModifierState({ | ||
| 44 | metaKey: false, | ||
| 45 | ctrlKey: false, | ||
| 46 | altKey: false, | ||
| 47 | shiftKey: false, | ||
| 48 | }); | ||
| 49 | |||
| 50 | expect(readShortcutModifierState()).toBe(initialState); | ||
| 51 | |||
| 52 | setShortcutModifierState({ | ||
| 53 | metaKey: false, | ||
| 54 | ctrlKey: true, | ||
| 55 | altKey: false, | ||
| 56 | shiftKey: false, | ||
| 57 | }); | ||
| 58 | const updatedState = readShortcutModifierState(); | ||
| 59 | expect(updatedState).not.toBe(initialState); | ||
| 60 | expect(updatedState).toEqual({ | ||
| 61 | metaKey: false, | ||
| 62 | ctrlKey: true, | ||
| 63 | altKey: false, | ||
| 64 | shiftKey: false, | ||
| 65 | }); | ||
| 66 | |||
| 67 | setShortcutModifierState({ | ||
| 68 | metaKey: false, | ||
| 69 | ctrlKey: true, | ||
| 70 | altKey: false, | ||
| 71 | shiftKey: false, | ||
| 72 | }); | ||
| 73 | expect(readShortcutModifierState()).toBe(updatedState); | ||
| 74 | |||
| 75 | clearShortcutModifierState(); | ||
| 76 | const clearedState = readShortcutModifierState(); | ||
| 77 | expect(clearedState).toEqual({ | ||
| 78 | metaKey: false, | ||
| 79 | ctrlKey: false, | ||
| 80 | altKey: false, | ||
| 81 | shiftKey: false, | ||
| 82 | }); | ||
| 83 | expect(clearedState).not.toBe(updatedState); | ||
| 84 | |||
| 85 | clearShortcutModifierState(); | ||
| 86 | expect(readShortcutModifierState()).toBe(clearedState); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("tracks bare modifier keydown and keyup events explicitly", () => { | ||
| 90 | clearShortcutModifierState(); | ||
| 91 | |||
| 92 | syncShortcutModifierStateFromKeyboardEvent( | ||
| 93 | keyboardEventLike("keydown", { | ||
| 94 | key: "Meta", | ||
| 95 | metaKey: false, | ||
| 96 | }), | ||
| 97 | ); | ||
| 98 | expect(readShortcutModifierState()).toEqual({ | ||
| 99 | metaKey: true, | ||
| 100 | ctrlKey: false, | ||
| 101 | altKey: false, | ||
| 102 | shiftKey: false, | ||
| 103 | }); | ||
| 104 | |||
| 105 | syncShortcutModifierStateFromKeyboardEvent( | ||
| 106 | keyboardEventLike("keydown", { | ||
| 107 | key: "Shift", | ||
| 108 | metaKey: true, | ||
| 109 | shiftKey: false, | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | expect(readShortcutModifierState()).toEqual({ | ||
| 113 | metaKey: true, | ||
| 114 | ctrlKey: false, | ||
| 115 | altKey: false, | ||
| 116 | shiftKey: true, | ||
| 117 | }); | ||
| 118 | |||
| 119 | syncShortcutModifierStateFromKeyboardEvent( | ||
| 120 | keyboardEventLike("keyup", { | ||
| 121 | key: "Meta", | ||
| 122 | metaKey: true, | ||
| 123 | shiftKey: true, | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | expect(readShortcutModifierState()).toEqual({ | ||
| 127 | metaKey: false, | ||
| 128 | ctrlKey: false, | ||
| 129 | altKey: false, | ||
| 130 | shiftKey: true, | ||
| 131 | }); | ||
| 132 | |||
| 133 | syncShortcutModifierStateFromKeyboardEvent( | ||
| 134 | keyboardEventLike("keyup", { | ||
| 135 | key: "Shift", | ||
| 136 | shiftKey: true, | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | expect(readShortcutModifierState()).toEqual({ | ||
| 140 | metaKey: false, | ||
| 141 | ctrlKey: false, | ||
| 142 | altKey: false, | ||
| 143 | shiftKey: false, | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | }); | ||
apps/web/src/shortcutModifierState.ts created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | import { create } from "zustand"; | ||
| 2 | |||
| 3 | export interface ShortcutModifierState { | ||
| 4 | metaKey: boolean; | ||
| 5 | ctrlKey: boolean; | ||
| 6 | altKey: boolean; | ||
| 7 | shiftKey: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | const EMPTY_SHORTCUT_MODIFIER_STATE: ShortcutModifierState = { | ||
| 11 | metaKey: false, | ||
| 12 | ctrlKey: false, | ||
| 13 | altKey: false, | ||
| 14 | shiftKey: false, | ||
| 15 | }; | ||
| 16 | |||
| 17 | export function areShortcutModifierStatesEqual( | ||
| 18 | left: ShortcutModifierState, | ||
| 19 | right: ShortcutModifierState, | ||
| 20 | ): boolean { | ||
| 21 | return ( | ||
| 22 | left.metaKey === right.metaKey && | ||
| 23 | left.ctrlKey === right.ctrlKey && | ||
| 24 | left.altKey === right.altKey && | ||
| 25 | left.shiftKey === right.shiftKey | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 29 | const useShortcutModifierStateStore = create<{ | ||
| 30 | state: ShortcutModifierState; | ||
| 31 | setState: (state: ShortcutModifierState) => void; | ||
| 32 | clear: () => void; | ||
| 33 | }>((set) => ({ | ||
| 34 | state: EMPTY_SHORTCUT_MODIFIER_STATE, | ||
| 35 | setState: (state) => | ||
| 36 | set((current) => (areShortcutModifierStatesEqual(current.state, state) ? current : { state })), | ||
| 37 | clear: () => | ||
| 38 | set((current) => | ||
| 39 | areShortcutModifierStatesEqual(current.state, EMPTY_SHORTCUT_MODIFIER_STATE) | ||
| 40 | ? current | ||
| 41 | : { state: EMPTY_SHORTCUT_MODIFIER_STATE }, | ||
| 42 | ), | ||
| 43 | })); | ||
| 44 | |||
| 45 | export function useShortcutModifierState(): ShortcutModifierState { | ||
| 46 | return useShortcutModifierStateStore((store) => store.state); | ||
| 47 | } | ||
| 48 | |||
| 49 | function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { | ||
| 50 | switch (key) { | ||
| 51 | case "Meta": | ||
| 52 | case "OS": | ||
| 53 | case "Command": | ||
| 54 | return "metaKey"; | ||
| 55 | case "Control": | ||
| 56 | return "ctrlKey"; | ||
| 57 | case "Alt": | ||
| 58 | case "Option": | ||
| 59 | return "altKey"; | ||
| 60 | case "Shift": | ||
| 61 | return "shiftKey"; | ||
| 62 | default: | ||
| 63 | return null; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void { | ||
| 68 | const normalizedModifierKey = normalizeModifierKey(event.key); | ||
| 69 | if (normalizedModifierKey) { | ||
| 70 | const currentState = useShortcutModifierStateStore.getState().state; | ||
| 71 | useShortcutModifierStateStore.getState().setState({ | ||
| 72 | ...currentState, | ||
| 73 | [normalizedModifierKey]: event.type === "keydown", | ||
| 74 | }); | ||
| 75 | return; | ||
| 76 | } | ||
| 77 | |||
| 78 | useShortcutModifierStateStore.getState().setState({ | ||
| 79 | metaKey: event.metaKey, | ||
| 80 | ctrlKey: event.ctrlKey, | ||
| 81 | altKey: event.altKey, | ||
| 82 | shiftKey: event.shiftKey, | ||
| 83 | }); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function setShortcutModifierState(state: ShortcutModifierState): void { | ||
| 87 | useShortcutModifierStateStore.getState().setState(state); | ||
| 88 | } | ||
| 89 | |||
| 90 | export function clearShortcutModifierState(): void { | ||
| 91 | useShortcutModifierStateStore.getState().clear(); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function readShortcutModifierState(): ShortcutModifierState { | ||
| 95 | return useShortcutModifierStateStore.getState().state; | ||
| 96 | } | ||
packages/contracts/src/keybindings.test.ts+12| ... | @@ -53,6 +53,18 @@ it.effect("parses keybinding rules", () => | ... | @@ -53,6 +53,18 @@ it.effect("parses keybinding rules", () => |
| 53 | }); | 53 | }); |
| 54 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); | 54 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); |
| 55 | 55 | ||
| 56 | const parsedModelPickerToggle = yield* decode(KeybindingRule, { | ||
| 57 | key: "mod+shift+m", | ||
| 58 | command: "modelPicker.toggle", | ||
| 59 | }); | ||
| 60 | assert.strictEqual(parsedModelPickerToggle.command, "modelPicker.toggle"); | ||
| 61 | |||
| 62 | const parsedModelPickerJump = yield* decode(KeybindingRule, { | ||
| 63 | key: "mod+1", | ||
| 64 | command: "modelPicker.jump.1", | ||
| 65 | }); | ||
| 66 | assert.strictEqual(parsedModelPickerJump.command, "modelPicker.jump.1"); | ||
| 67 | |||
| 56 | const parsedThreadPrevious = yield* decode(KeybindingRule, { | 68 | const parsedThreadPrevious = yield* decode(KeybindingRule, { |
| 57 | key: "mod+shift+[", | 69 | key: "mod+shift+[", |
| 58 | command: "thread.previous", | 70 | command: "thread.previous", |
packages/contracts/src/keybindings.ts+21| ... | @@ -20,6 +20,20 @@ export const THREAD_JUMP_KEYBINDING_COMMANDS = [ | ... | @@ -20,6 +20,20 @@ export const THREAD_JUMP_KEYBINDING_COMMANDS = [ |
| 20 | ] as const; | 20 | ] as const; |
| 21 | export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; | 21 | export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; |
| 22 | 22 | ||
| 23 | export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [ | ||
| 24 | "modelPicker.jump.1", | ||
| 25 | "modelPicker.jump.2", | ||
| 26 | "modelPicker.jump.3", | ||
| 27 | "modelPicker.jump.4", | ||
| 28 | "modelPicker.jump.5", | ||
| 29 | "modelPicker.jump.6", | ||
| 30 | "modelPicker.jump.7", | ||
| 31 | "modelPicker.jump.8", | ||
| 32 | "modelPicker.jump.9", | ||
| 33 | ] as const; | ||
| 34 | export type ModelPickerJumpKeybindingCommand = | ||
| 35 | (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; | ||
| 36 | |||
| 23 | export const THREAD_KEYBINDING_COMMANDS = [ | 37 | export const THREAD_KEYBINDING_COMMANDS = [ |
| 24 | "thread.previous", | 38 | "thread.previous", |
| 25 | "thread.next", | 39 | "thread.next", |
| ... | @@ -27,6 +41,12 @@ export const THREAD_KEYBINDING_COMMANDS = [ | ... | @@ -27,6 +41,12 @@ export const THREAD_KEYBINDING_COMMANDS = [ |
| 27 | ] as const; | 41 | ] as const; |
| 28 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; | 42 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; |
| 29 | 43 | ||
| 44 | export const MODEL_PICKER_KEYBINDING_COMMANDS = [ | ||
| 45 | "modelPicker.toggle", | ||
| 46 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, | ||
| 47 | ] as const; | ||
| 48 | export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; | ||
| 49 | |||
| 30 | const STATIC_KEYBINDING_COMMANDS = [ | 50 | const STATIC_KEYBINDING_COMMANDS = [ |
| 31 | "terminal.toggle", | 51 | "terminal.toggle", |
| 32 | "terminal.split", | 52 | "terminal.split", |
| ... | @@ -37,6 +57,7 @@ const STATIC_KEYBINDING_COMMANDS = [ | ... | @@ -37,6 +57,7 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 37 | "chat.new", | 57 | "chat.new", |
| 38 | "chat.newLocal", | 58 | "chat.newLocal", |
| 39 | "editor.openFavorite", | 59 | "editor.openFavorite", |
| 60 | ...MODEL_PICKER_KEYBINDING_COMMANDS, | ||
| 40 | ...THREAD_KEYBINDING_COMMANDS, | 61 | ...THREAD_KEYBINDING_COMMANDS, |
| 41 | ] as const; | 62 | ] as const; |
| 42 | 63 |
packages/contracts/src/server.ts+2| ... | @@ -53,6 +53,8 @@ export type ServerProviderAuth = typeof ServerProviderAuth.Type; | ... | @@ -53,6 +53,8 @@ export type ServerProviderAuth = typeof ServerProviderAuth.Type; |
| 53 | export const ServerProviderModel = Schema.Struct({ | 53 | export const ServerProviderModel = Schema.Struct({ |
| 54 | slug: TrimmedNonEmptyString, | 54 | slug: TrimmedNonEmptyString, |
| 55 | name: TrimmedNonEmptyString, | 55 | name: TrimmedNonEmptyString, |
| 56 | shortName: Schema.optional(TrimmedNonEmptyString), | ||
| 57 | subProvider: Schema.optional(TrimmedNonEmptyString), | ||
| 56 | isCustom: Schema.Boolean, | 58 | isCustom: Schema.Boolean, |
| 57 | capabilities: Schema.NullOr(ModelCapabilities), | 59 | capabilities: Schema.NullOr(ModelCapabilities), |
| 58 | }); | 60 | }); |
packages/contracts/src/settings.ts+30-1| ... | @@ -9,7 +9,7 @@ import { | ... | @@ -9,7 +9,7 @@ import { |
| 9 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 9 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 10 | OpenCodeModelOptions, | 10 | OpenCodeModelOptions, |
| 11 | } from "./model.ts"; | 11 | } from "./model.ts"; |
| 12 | import { ModelSelection } from "./orchestration.ts"; | 12 | import { ModelSelection, ProviderKind } from "./orchestration.ts"; |
| 13 | 13 | ||
| 14 | // ── Client Settings (local-only) ─────────────────────────────── | 14 | // ── Client Settings (local-only) ─────────────────────────────── |
| 15 | 15 | ||
| ... | @@ -37,6 +37,12 @@ export const ClientSettingsSchema = Schema.Struct({ | ... | @@ -37,6 +37,12 @@ export const ClientSettingsSchema = Schema.Struct({ |
| 37 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 37 | confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 38 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 38 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), |
| 39 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 39 | diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 40 | favorites: Schema.Array( | ||
| 41 | Schema.Struct({ | ||
| 42 | provider: ProviderKind, | ||
| 43 | model: TrimmedNonEmptyString, | ||
| 44 | }), | ||
| 45 | ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | ||
| 40 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( | 46 | sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( |
| 41 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), | 47 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), |
| 42 | ), | 48 | ), |
| ... | @@ -241,6 +247,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ | ... | @@ -241,6 +247,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ |
| 241 | }); | 247 | }); |
| 242 | 248 | ||
| 243 | export const ServerSettingsPatch = Schema.Struct({ | 249 | export const ServerSettingsPatch = Schema.Struct({ |
| 250 | // Server settings | ||
| 244 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), | 251 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 245 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), | 252 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| 246 | addProjectBaseDirectory: Schema.optionalKey(Schema.String), | 253 | addProjectBaseDirectory: Schema.optionalKey(Schema.String), |
| ... | @@ -261,3 +268,25 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -261,3 +268,25 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 261 | ), | 268 | ), |
| 262 | }); | 269 | }); |
| 263 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; | 270 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 271 | |||
| 272 | export const ClientSettingsPatch = Schema.Struct({ | ||
| 273 | confirmThreadArchive: Schema.optionalKey(Schema.Boolean), | ||
| 274 | confirmThreadDelete: Schema.optionalKey(Schema.Boolean), | ||
| 275 | diffWordWrap: Schema.optionalKey(Schema.Boolean), | ||
| 276 | favorites: Schema.optionalKey( | ||
| 277 | Schema.Array( | ||
| 278 | Schema.Struct({ | ||
| 279 | provider: ProviderKind, | ||
| 280 | model: TrimmedNonEmptyString, | ||
| 281 | }), | ||
| 282 | ), | ||
| 283 | ), | ||
| 284 | sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), | ||
| 285 | sidebarProjectGroupingOverrides: Schema.optionalKey( | ||
| 286 | Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), | ||
| 287 | ), | ||
| 288 | sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), | ||
| 289 | sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), | ||
| 290 | timestampFormat: Schema.optionalKey(TimestampFormat), | ||
| 291 | }); | ||
| 292 | export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; |