authorgravatar for hello@unknownhost.nameEllie Gummere <hello@unknownhost.name> 2026-04-19 20:43:40-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-19 17:43:40-07:00
log66c326b8c424ca1e3702232a4fe5a06f6ba2a525
tree6b906a586d1a141007660e7cda106f8aba762b44
parent8dbcf92a0d125050988474f258df3e55c538efec
signature Signed by PGP key B5690EEEBB952194

Redesign model picker with favorites and search (#2153)

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 );
197200
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];
7683
77function normalizeKeyToken(token: string): string {84function normalizeKeyToken(token: string): string {
apps/server/src/provider/opencodeRuntime.test.ts+29-30
...@@ -1,37 +1,36 @@...@@ -1,37 +1,36 @@
1import assert from "node:assert/strict";1import { describe, expect, it } from "vitest";
22
3import { describe, it, vi } from "vitest";3import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts";
44
5const childProcessMock = vi.hoisted(() => ({5describe("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",
1414 models: {
15vi.mock("node:child_process", () => childProcessMock);15 "claude-opus-4.5": {
1616 id: "claude-opus-4.5",
17describe("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 },
2020 },
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 },
2424 agents: [],
25 it("resolves command names through PATH", async () => {25 } as unknown as Parameters<typeof flattenOpenCodeModels>[0]);
26 const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts");
2726
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";
33
4import ThreadSidebar from "./Sidebar";4import ThreadSidebar from "./Sidebar";
5import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";5import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
6import {
7 clearShortcutModifierState,
8 syncShortcutModifierStateFromKeyboardEvent,
9} from "../shortcutModifierState";
610
7const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";11const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width";
8const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;12const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
9const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;13const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
10
11export function AppSidebarLayout({ children }: { children: ReactNode }) {14export function AppSidebarLayout({ children }: { children: ReactNode }) {
12 const navigate = useNavigate();15 const navigate = useNavigate();
1316
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 }
1944
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 });
2450
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}
13861386
1387function 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
1387async function triggerChatNewShortcutUntilPath(1399async 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 });
55225557
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 };
22372238
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 }
22842285
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);
153153
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}
172176
apps/web/src/components/Icons.tsx+33-10
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { type SVGProps, useId } from "react";1import { type SVGProps, useId } from "react";
2import { cn } from "~/lib/utils";
23
3export type Icon = React.FC<SVGProps<SVGSVGElement>>;4export type Icon = React.FC<SVGProps<SVGSVGElement>>;
45
...@@ -14,8 +15,12 @@ export const GitHubIcon: Icon = (props) => (...@@ -14,8 +15,12 @@ export const GitHubIcon: Icon = (props) => (
14 </svg>15 </svg>
15);16);
1617
17export const CursorIcon: Icon = (props) => (18export 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};
292297
293export const OpenAI: Icon = (props) => (298export 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);
298308
299export const ClaudeAI: Icon = (props) => (309export const ClaudeAI: Icon = ({ className, ...props }) => (
300 <svg {...props} preserveAspectRatio="xMidYMid" viewBox="0 0 256 257">310 <svg
301 <path311 {...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);
307319
...@@ -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
581export 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";
75import {75import {
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";
83import { useModelPickerOpen } from "../modelPickerOpenState";
84import { useShortcutModifierState } from "../shortcutModifierState";
83import { useGitStatus } from "../lib/gitStatusState";85import { useGitStatus } from "../lib/gitStatusState";
84import { readLocalApi } from "../localApi";86import { readLocalApi } from "../localApi";
85import { useComposerDraftStore } from "../composerDraftStore";87import { 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};
201203
202function 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
220function formatProjectMemberActionLabel(204function 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 ).terminalOpen2809 ).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 = showThreadJumpHints3037 const visibleThreadJumpLabelByKey = showThreadJumpHints
3026 ? threadJumpLabelByKey3038 ? 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]);
30533065
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;
30723069
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 }
31013073
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 };
31493121
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);
31823123
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 ]);
32003137
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";
61import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";62import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor";
62import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker";63import { ProviderModelPicker } from "./ProviderModelPicker";
63import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";64import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
64import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";65import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
65import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";66import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
...@@ -317,6 +318,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -317,6 +318,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
317export interface ChatComposerHandle {318export 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 // Misc414 // 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;
414420
415 // Refs the parent needs kept in sync421 // 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 ? selectedModelForPicker630 ? 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 );
642633
643 // ------------------------------------------------------------------634 // ------------------------------------------------------------------
644 // Context window635 // 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);
667659
668 // ------------------------------------------------------------------660 // ------------------------------------------------------------------
669 // Refs661 // 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 searchableModelOptions777 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 ]);
810779
811 const composerMenuOpen = Boolean(composerTrigger);780 const composerMenuOpen = Boolean(composerTrigger);
812 const composerMenuSearchKey = composerTrigger781 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 );
14571409
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.modelPickerIconClassName1892 {...(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 />
19371903
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";
10import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";10import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
11import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";11import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";
12import { cn } from "~/lib/utils";12import { cn } from "~/lib/utils";
13import { Badge } from "../ui/badge";
14import {13import {
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 @@
1import { type ProviderKind } from "@t3tools/contracts";
2import { memo } from "react";
3import { StarIcon } from "lucide-react";
4import {
5 getDisplayModelName,
6 getProviderLabel,
7 getTriggerDisplayModelLabel,
8 type ModelEsque,
9 PROVIDER_ICON_BY_PROVIDER,
10} from "./providerIconUtils";
11import { ComboboxItem } from "../ui/combobox";
12import { Kbd } from "../ui/kbd";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14import { cn } from "~/lib/utils";
15
16export 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 @@
1import {
2 type ProviderKind,
3 PROVIDER_DISPLAY_NAMES,
4 type ResolvedKeybindingsConfig,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import { resolveSelectableModel } from "@t3tools/shared/model";
8import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
9import { SearchIcon } from "lucide-react";
10import { ModelListRow } from "./ModelListRow";
11import { ModelPickerSidebar } from "./ModelPickerSidebar";
12import { isModelPickerNewModel } from "./modelPickerModelHighlights";
13import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
14import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox";
15import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
16import {
17 modelPickerJumpCommandForIndex,
18 modelPickerJumpIndexFromCommand,
19 resolveShortcutCommand,
20 shortcutLabelForCommand,
21} from "../../keybindings";
22import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
23import { cn } from "~/lib/utils";
24import { TooltipProvider } from "../ui/tooltip";
25
26type ModelPickerItem = {
27 slug: string;
28 name: string;
29 shortName?: string;
30 subProvider?: string;
31 provider: ProviderKind;
32};
33
34const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
35
36export 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 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";
3import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react";
4import { Gemini, GithubCopilotIcon } from "../Icons";
5import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { cn } from "~/lib/utils";
8import { getProviderSnapshot } from "../../providerModels";
9
10function 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
29const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm";
30const 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";
32const 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 ";
34const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `;
35const 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). */
38const PICKER_TOOLTIP_SIDE = "left" as const;
39const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
40
41export 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 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";
2import { page } from "vitest/browser";2import { EnvironmentId } from "@t3tools/contracts";
3import { afterEach, describe, expect, it, vi } from "vitest";3import { page, userEvent } from "vitest/browser";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4import { render } from "vitest-browser-react";5import { render } from "vitest-browser-react";
56
6import { ProviderModelPicker } from "./ProviderModelPicker";7import { ProviderModelPicker } from "./ProviderModelPicker";
7import { getCustomModelOptionsByProvider } from "../../modelSelection";8import { getCustomModelOptionsByProvider } from "../../modelSelection";
8import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";9import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
10import { __resetLocalApiForTests } from "../../localApi";
11
12// Mock the environments/runtime module to provide a mock primary environment connection
13vi.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});
968
10function effort(value: string, isDefault = false) {69function 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}
131190
191function 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
132async function mountPicker(props: {206async 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}
170244
245function getModelPickerListElement() {
246 const modelPickerList = document.querySelector<HTMLElement>(".model-picker-list");
247 expect(modelPickerList).not.toBeNull();
248 return modelPickerList!;
249}
250
251function getModelPickerListText() {
252 return getModelPickerListElement().textContent ?? "";
253}
254
255function 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
261function getSidebarProviderOrder() {
262 return Array.from(document.querySelectorAll<HTMLElement>("[data-model-picker-provider]")).map(
263 (element) => element.dataset.modelPickerProvider ?? "",
264 );
265}
266
171describe("ProviderModelPicker", () => {267describe("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 });
175277
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", () => {
185287
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 });
196298
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", () => {
203305
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();
208308
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 });
212320
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 });
219327
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 }
227330
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 });
232337
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();
234343
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 });
241354
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 });
262904
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 ];
310952
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 });
317959
318 try {960 try {
319 await page.getByRole("button").click();961 await page.getByRole("button").click();
320 await page.getByRole("menuitem", { name: "Codex" }).hover();
321962
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 }
330971
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 });
337978
338 try {979 try {
339 await page.getByRole("button").click();980 await page.getByRole("button").click();
340 await page.getByRole("menuitem", { name: "Codex" }).hover();
341981
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 });
349989
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", () => {
3921013
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 @@
1import { type ProviderKind, type ServerProvider } from "@t3tools/contracts";1import {
2import { resolveSelectableModel } from "@t3tools/shared/model";2 type ProviderKind,
3import { memo, useState } from "react";3 type ResolvedKeybindingsConfig,
4 type ServerProvider,
5} from "@t3tools/contracts";
6import { memo, useEffect, useState } from "react";
4import type { VariantProps } from "class-variance-authority";7import type { VariantProps } from "class-variance-authority";
5import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic";
6import { ChevronDownIcon } from "lucide-react";8import { ChevronDownIcon } from "lucide-react";
7import { Button, buttonVariants } from "../ui/button";9import { Button, buttonVariants } from "../ui/button";
8import {10import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
9 Menu,11import { 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";
21import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons";
22import { cn } from "~/lib/utils";12import { cn } from "~/lib/utils";
23import { getProviderSnapshot } from "../../providerModels";13import { ModelPickerContent } from "./ModelPickerContent";
2414import {
25function 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;20import { setModelPickerOpen } from "../../modelPickerOpenState";
31}
32
33const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
34 codex: OpenAI,
35 claudeAgent: ClaudeAI,
36 opencode: OpenCodeIcon,
37 cursor: CursorIcon,
38};
39
40export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
41const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
42const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const;
43
44function providerIconClassName(
45 provider: ProviderKind | ProviderPickerKind,
46 fallbackClassName: string,
47): string {
48 return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName;
49}
5021
51export const ProviderModelPicker = memo(function ProviderModelPicker(props: {22export 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 };
8273
83 return (74 return (
84 <Menu75 <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 <MenuTrigger85 <PopoverTrigger
95 render={86 render={
96 <Button87 <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 <ProviderIcon106 <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 <MenuRadioGroup117 ? "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 <OptionIcon125 <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 @@
1import 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 */
7const NEW_MODEL_KEYS = new Set<string>([
8 // Example: "claudeAgent:claude-opus-4-7",
9]);
10
11export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
4
5describe("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
17describe("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 @@
1import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
2import { normalizeSearchQuery, scoreQueryMatch } from "@t3tools/shared/searchRanking";
3
4type ModelPickerSearchableModel = {
5 provider: ProviderKind;
6 name: string;
7 shortName?: string;
8 subProvider?: string;
9 isFavorite?: boolean;
10};
11
12const MODEL_PICKER_FAVORITE_SCORE_BOOST = 24;
13
14function 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
25function 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
41export 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
55export 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 @@
1import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
2import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
3import { PROVIDER_OPTIONS } from "../../session-logic";
4
5export const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
6 codex: OpenAI,
7 claudeAgent: ClaudeAI,
8 opencode: OpenCodeIcon,
9 cursor: CursorIcon,
10};
11
12function 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
21export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
22
23export type ModelEsque = {
24 slug: string;
25 name: string;
26 shortName?: string | undefined;
27 subProvider?: string | undefined;
28};
29
30export function getProviderLabel(provider: ProviderKind, model: ModelEsque): string {
31 const providerLabel = PROVIDER_DISPLAY_NAMES[provider];
32 return model.subProvider ? `${providerLabel} · ${model.subProvider}` : providerLabel;
33}
34
35export 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
45export function getTriggerDisplayModelName(model: ModelEsque): string {
46 return getDisplayModelName(model, { preferShortName: true });
47}
48
49export 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.List195 <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({
186186
187function ComboboxItem({187function ComboboxItem({
188 className,188 className,
189 contentClassName,
189 children,190 children,
190 hideIndicator = false,191 hideIndicator = false,
191 ...props192 ...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.List275 <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.Viewport24 <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 });
3838
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);
4242
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 });
5050
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 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";2import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext";
33
4export type ComposerTriggerKind = "path" | "slash-command" | "slash-model" | "skill";4export type ComposerTriggerKind = "path" | "slash-command" | "skill";
5export type ComposerSlashCommand = "model" | "plan" | "default";5export type ComposerSlashCommand = "model" | "plan" | "default";
66
7export interface ComposerTrigger {7export 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 }
253235
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 */
12import { useCallback, useMemo, useSyncExternalStore } from "react";12import { useCallback, useMemo, useSyncExternalStore } from "react";
13import { ServerSettings, ServerSettingsPatch } from "@t3tools/contracts";13import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
14import {14import {
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))
9899
99function splitPatch(patch: Partial<UnifiedSettings>): {100function 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}
117118
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]);
119142
120describe("isTerminalToggleShortcut", () => {143describe("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 });
284318
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});
363397
398describe("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
364describe("chat/editor shortcuts", () => {424describe("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";
9import { isMacPlatform } from "./lib/utils";11import { 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}
2022
23export interface ShortcutModifierStateLike {
24 metaKey: boolean;
25 ctrlKey: boolean;
26 shiftKey: boolean;
27 altKey: boolean;
28}
29
21export interface ShortcutMatchContext {30export 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}
7281
73function matchesShortcutModifiers(82function 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
285export 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);
274291
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
303export function modelPickerJumpCommandForIndex(
304 index: number,
305): ModelPickerJumpKeybindingCommand | null {
306 return MODEL_PICKER_JUMP_KEYBINDING_COMMANDS[index] ?? null;
307}
308
309export 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
316export function shouldShowModelPickerJumpHints(
317 event: ShortcutEventLike,
318 keybindings: ResolvedKeybindingsConfig,
319 options?: ShortcutMatchOptions,
320): boolean {
321 return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options);
322}
323
324export 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 @@
1import { create } from "zustand";
2
3const 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
11export function useModelPickerOpen(): boolean {
12 return useModelPickerOpenStore((store) => store.open);
13}
14
15export 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";
19import { ModelEsque } from "./components/chat/providerIconUtils";
1920
20const MAX_CUSTOM_MODEL_COUNT = 32;21const MAX_CUSTOM_MODEL_COUNT = 32;
21export const MAX_CUSTOM_MODEL_LENGTH = 256;22export const MAX_CUSTOM_MODEL_LENGTH = 256;
...@@ -31,6 +32,8 @@ export type ProviderCustomModelConfig = {...@@ -31,6 +32,8 @@ export type ProviderCustomModelConfig = {
31export interface AppModelOption {32export 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}
3639
...@@ -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];
3739
38export interface WorkLogEntry {40export interface WorkLogEntry {
apps/web/src/shortcutModifierState.test.ts created+146
...@@ -0,0 +1,146 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 areShortcutModifierStatesEqual,
5 clearShortcutModifierState,
6 readShortcutModifierState,
7 setShortcutModifierState,
8 syncShortcutModifierStateFromKeyboardEvent,
9} from "./shortcutModifierState";
10
11function 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
23describe("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 @@
1import { create } from "zustand";
2
3export interface ShortcutModifierState {
4 metaKey: boolean;
5 ctrlKey: boolean;
6 altKey: boolean;
7 shiftKey: boolean;
8}
9
10const EMPTY_SHORTCUT_MODIFIER_STATE: ShortcutModifierState = {
11 metaKey: false,
12 ctrlKey: false,
13 altKey: false,
14 shiftKey: false,
15};
16
17export 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
29const 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
45export function useShortcutModifierState(): ShortcutModifierState {
46 return useShortcutModifierStateStore((store) => store.state);
47}
48
49function 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
67export 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
86export function setShortcutModifierState(state: ShortcutModifierState): void {
87 useShortcutModifierStateStore.getState().setState(state);
88}
89
90export function clearShortcutModifierState(): void {
91 useShortcutModifierStateStore.getState().clear();
92}
93
94export 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");
5555
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;
21export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number];21export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number];
2222
23export 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;
34export type ModelPickerJumpKeybindingCommand =
35 (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number];
36
23export const THREAD_KEYBINDING_COMMANDS = [37export 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;
28export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];42export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
2943
44export const MODEL_PICKER_KEYBINDING_COMMANDS = [
45 "modelPicker.toggle",
46 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
47] as const;
48export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];
49
30const STATIC_KEYBINDING_COMMANDS = [50const 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;
4263
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;
53export const ServerProviderModel = Schema.Struct({53export 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";
12import { ModelSelection } from "./orchestration.ts";12import { ModelSelection, ProviderKind } from "./orchestration.ts";
1313
14// ── Client Settings (local-only) ───────────────────────────────14// ── Client Settings (local-only) ───────────────────────────────
1515
...@@ -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});
242248
243export const ServerSettingsPatch = Schema.Struct({249export 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});
263export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;270export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
271
272export 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});
292export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;