From c83bc5d48a2bf983acb1c8aaff3d34f86c14032e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:58:04 +0300 Subject: [PATCH 01/13] fix(release): use v tag format for nightly releases (#2186) --- .github/workflows/release.yml | 3 ++- scripts/release-smoke.ts | 2 +- scripts/resolve-nightly-release.test.ts | 2 +- scripts/resolve-nightly-release.ts | 2 +- scripts/resolve-previous-release-tag.ts | 12 ++++++++++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25b4ac213c..366a6b4de3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*.*.*" + - "!v*-nightly.*" schedule: - cron: "0 */3 * * *" workflow_dispatch: @@ -41,7 +42,7 @@ jobs: - id: check name: Compare HEAD to last nightly tag run: | - last_nightly_tag=$(git tag --list 'nightly-v*' --sort=-creatordate | head -n 1) + last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) if [[ -z "$last_nightly_tag" ]]; then echo "No previous nightly tag found. Proceeding with release." echo "has_changes=true" >> "$GITHUB_OUTPUT" diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 41f948c685..362771d12e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -220,7 +220,7 @@ try { ); assertContains( nightlyReleaseMetadata, - "tag=nightly-v9.9.10-nightly.20260413.321", + "tag=v9.9.10-nightly.20260413.321", "Expected nightly metadata to contain the derived nightly tag.", ); assertContains( diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 56358d6c14..82b25737a5 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -24,7 +24,7 @@ it("derives nightly metadata including the short commit sha in the release name" { baseVersion: "9.9.10", version: "9.9.10-nightly.20260413.321", - tag: "nightly-v9.9.10-nightly.20260413.321", + tag: "v9.9.10-nightly.20260413.321", name: "T3 Code Nightly 9.9.10-nightly.20260413.321 (abcdef123456)", shortSha: "abcdef123456", }, diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index 4a92ef63ae..5f9413db20 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -54,7 +54,7 @@ export const resolveNightlyReleaseMetadata = ( return { baseVersion, version, - tag: `nightly-v${version}`, + tag: `v${version}`, name: `T3 Code Nightly ${version} (${shortSha})`, shortSha, }; diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 93f932821f..5f2ef1316b 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -75,11 +75,17 @@ const parseStableTag = (tag: string): StableVersion | undefined => { const [, major, minor, patch, prerelease] = match; if (!major || !minor || !patch) return undefined; + const prereleaseIdentifiers = prerelease ? prerelease.split(".") : []; + // Nightly tags also start with `v` and carry a `nightly.*` prerelease + // identifier. They must not be considered stable candidates when resolving + // the previous stable tag. + if (prereleaseIdentifiers[0] === "nightly") return undefined; + return { major: Number(major), minor: Number(minor), patch: Number(patch), - prerelease: prerelease ? prerelease.split(".") : [], + prerelease: prereleaseIdentifiers, }; }; @@ -92,7 +98,9 @@ const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): nu }; const parseNightlyTag = (tag: string): NightlyVersion | undefined => { - const match = /^nightly-v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); + // Accept both the current `v` format and the legacy `nightly-v` + // format so release note diffs keep working across the tag-format transition. + const match = /^(?:nightly-)?v(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/.exec(tag); if (!match) return undefined; const [, major, minor, patch, date, runNumber] = match; From 20f346d8ef58b21c339bc956c1728f4f16d09a87 Mon Sep 17 00:00:00 2001 From: "Alton Johnson, OSCP, OSCE" <4023423+altjx@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:06:19 -0400 Subject: [PATCH 02/13] Expand leading ~ in Codex home paths before exporting CODEX_HOME (#2210) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: = <=> --- apps/server/src/codexAppServerManager.ts | 5 +-- .../src/git/Layers/CodexTextGeneration.ts | 5 ++- apps/server/src/pathExpansion.test.ts | 33 +++++++++++++++++++ apps/server/src/pathExpansion.ts | 23 +++++++++++++ .../src/provider/Layers/CodexProvider.ts | 3 +- apps/server/src/provider/codexAppServer.ts | 3 +- 6 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/pathExpansion.test.ts create mode 100644 apps/server/src/pathExpansion.ts diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 6d98264c91..ba776890ee 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -32,6 +32,7 @@ import { type CodexAccountSnapshot, } from "./provider/codexAccount.ts"; import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts"; +import { expandHomePath } from "./pathExpansion.ts"; export { buildCodexInitializeParams } from "./provider/codexAppServer.ts"; export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts"; @@ -493,7 +494,7 @@ export class CodexAppServerManager extends EventEmitter { + it("returns an empty string unchanged", () => { + expect(expandHomePath("")).toBe(""); + }); + + it("returns paths without a leading tilde unchanged", () => { + expect(expandHomePath("/absolute/path")).toBe("/absolute/path"); + expect(expandHomePath("relative/path")).toBe("relative/path"); + expect(expandHomePath("some~weird~path")).toBe("some~weird~path"); + }); + + it("expands a lone tilde to the home directory", () => { + expect(expandHomePath("~")).toBe(homedir()); + }); + + it("expands ~/ to a subpath of the home directory", () => { + expect(expandHomePath("~/.codex-work")).toBe(join(homedir(), ".codex-work")); + }); + + it("expands a Windows-style ~\\ prefix", () => { + expect(expandHomePath("~\\.codex")).toBe(join(homedir(), ".codex")); + }); + + it("does not expand ~user paths", () => { + expect(expandHomePath("~alice/foo")).toBe("~alice/foo"); + }); +}); diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts new file mode 100644 index 0000000000..18060c3e55 --- /dev/null +++ b/apps/server/src/pathExpansion.ts @@ -0,0 +1,23 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the + * current user's home directory. Spawned processes don't get shell + * expansion, so env vars like `CODEX_HOME=~/.codex-work` would be passed + * verbatim and treated as relative paths by the receiver. + * + * Matches the behavior of the other `expandHomePath` helpers in the + * workspace layers and CLI bootstrap: `~` alone and both `~/` and `~\` + * separators are handled. Returns the input unchanged if it doesn't + * start with `~` or is empty. Does not handle `~user` (other-user) + * expansion. + */ +export function expandHomePath(value: string): string { + if (!value) return value; + if (value === "~") return homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return join(homedir(), value.slice(2)); + } + return value; +} diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index de4aceeac9..ab2f962eae 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -47,6 +47,7 @@ import { } from "../codexAccount.ts"; import { probeCodexDiscovery } from "../codexAppServer.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerSettingsError } from "@t3tools/contracts"; @@ -325,7 +326,7 @@ const runCodexCommand = Effect.fn("runCodexCommand")(function* (args: ReadonlyAr shell: process.platform === "win32", env: { ...process.env, - ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + ...(codexSettings.homePath ? { CODEX_HOME: expandHomePath(codexSettings.homePath) } : {}), }, }); return yield* spawnAndCollect(codexSettings.binaryPath, command); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 24a9e29c59..7806e135fe 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:chil import readline from "node:readline"; import type { ServerProviderSkill } from "@t3tools/contracts"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount.ts"; +import { expandHomePath } from "../pathExpansion.ts"; interface JsonRpcProbeResponse { readonly id?: unknown; @@ -115,7 +116,7 @@ export async function probeCodexDiscovery(input: { const child = spawn(input.binaryPath, ["app-server"], { env: { ...process.env, - ...(input.homePath ? { CODEX_HOME: input.homePath } : {}), + ...(input.homePath ? { CODEX_HOME: expandHomePath(input.homePath) } : {}), }, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", From 57b59b5bf209837db3ae911f2ca181f8f1dc2df7 Mon Sep 17 00:00:00 2001 From: Kyle Gottfried <6462596+Spitfire1900@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:07:12 -0400 Subject: [PATCH 03/13] Devcontainer / IDE updates (#2208) --- .devcontainer/devcontainer.json | 15 +++++++++++---- .oxfmtrc.json | 10 +++++++++- .vscode/settings.json | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0c4c30240c..cc1f11cd8b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,15 +2,22 @@ "name": "T3 Code Dev", "image": "debian:bookworm", "features": { - "ghcr.io/devcontainers-extra/features/bun:1": {}, + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers-extra/features/bun:1": { + "version": "1.3.11" + }, "ghcr.io/devcontainers/features/node:1": { - "version": "24", - "nodeGypDependencies": true + "version": "24.13.1" }, "ghcr.io/devcontainers/features/python:1": { - "version": "3.12" + "version": "3.10", + "installTools": false } }, + "overrideFeatureInstallOrder": [ + "ghcr.io/devcontainers/features/git", + "ghcr.io/devcontainers-extra/features/bun" + ], "postCreateCommand": { "bun-install": "bun install --backend=copyfile --frozen-lockfile" }, diff --git a/.oxfmtrc.json b/.oxfmtrc.json index dded6b0acd..3d65d9c93b 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -13,5 +13,13 @@ "apps/web/src/lib/vendor/qrcodegen.ts", "*.icon/**" ], - "sortPackageJson": {} + "sortPackageJson": {}, + "overrides": [ + { + "files": [".devcontainer/devcontainer.json"], + "options": { + "trailingComma": "none" + } + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 752d9a9071..8a1b614ddd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,5 @@ "source.fixAll.oxc": "always" }, "oxc.unusedDisableDirectives": "warn", - "typescript.tsdk": "node_modules/typescript/lib" + "js/ts.tsdk.path": "node_modules/typescript/lib" } From 37965da01d34e50a67f92cd7fb978b74a6cf8fcf Mon Sep 17 00:00:00 2001 From: Abdul Azeez Date: Mon, 20 Apr 2026 05:42:35 +0530 Subject: [PATCH 04/13] =?UTF-8?q?fix(server):=20handle=20OpenCode=20text?= =?UTF-8?q?=20response=20format=20in=20commit=20message=20g=E2=80=A6=20(#2?= =?UTF-8?q?202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/git/Layers/CursorTextGeneration.ts | 55 ++----------- .../git/Layers/OpenCodeTextGeneration.test.ts | 81 +++++++++++++++++-- .../src/git/Layers/OpenCodeTextGeneration.ts | 68 +++++++++++++--- apps/server/src/git/Utils.ts | 48 +++++++++++ 4 files changed, 184 insertions(+), 68 deletions(-) diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 754f3737eb..24f066059c 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -16,7 +16,12 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "../Prompts.ts"; -import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts"; +import { + extractJsonObject, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "../Utils.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime, @@ -25,54 +30,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const CURSOR_TIMEOUT_MS = 180_000; -function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const start = trimmed.indexOf("{"); - if (start < 0) { - return trimmed; - } - - let depth = 0; - let inString = false; - let escaping = false; - for (let index = start; index < trimmed.length; index += 1) { - const char = trimmed[index]; - if (inString) { - if (escaping) { - escaping = false; - } else if (char === "\\") { - escaping = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "{") { - depth += 1; - continue; - } - - if (char === "}") { - depth -= 1; - if (depth === 0) { - return trimmed.slice(start, index + 1); - } - } - } - - return trimmed.slice(start); -} - function mapCursorAcpError( operation: | "generateCommitMessage" diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts index 4cf25c9468..ab98024567 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -17,7 +17,9 @@ const runtimeMock = vi.hoisted(() => { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], - promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, + promptResult: undefined as + | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | undefined, }; return { @@ -63,12 +65,15 @@ vi.mock("../../provider/opencodeRuntime.ts", async () => { return ( runtimeMock.state.promptResult ?? { data: { - info: { - structured: { - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", + parts: [ + { + type: "text", + text: JSON.stringify({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }), }, - }, + ], }, } ); @@ -198,7 +203,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => }).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => + it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => Effect.gen(function* () { runtimeMock.state.promptResult = { data: {} }; const textGeneration = yield* TextGeneration; @@ -213,7 +218,67 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => }) .pipe(Effect.flip); - expect(error.message).toContain("OpenCode returned no structured output."); + expect(error.message).toContain("OpenCode returned empty output."); + }), + ); + + it.effect("parses JSON returned as plain text output", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', + }, + ], + }, + }; + const textGeneration = yield* TextGeneration; + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(result).toEqual({ + subject: "Tighten OpenCode parsing", + body: "Handle JSON text output locally.", + }); + }), + ); + + it.effect("surfaces the upstream OpenCode structured-output error message", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + info: { + error: { + name: "StructuredOutputError", + data: { + message: "Model did not produce structured output", + retries: 2, + }, + }, + }, + }, + }; + const textGeneration = yield* TextGeneration; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("Model did not produce structured output"); }), ); }); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index 7721354e4d..d206e59e8d 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -19,10 +19,10 @@ import { } from "../Prompts.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { + extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, - toJsonSchemaObject, } from "../Utils.ts"; import { createOpenCodeSdkClient, @@ -35,6 +35,49 @@ import { const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; +function getOpenCodePromptErrorMessage(error: unknown): string | null { + if (!error || typeof error !== "object") { + return null; + } + + const message = + "data" in error && + error.data && + typeof error.data === "object" && + "message" in error.data && + typeof error.data.message === "string" + ? error.data.message.trim() + : ""; + if (message.length > 0) { + return message; + } + + if ("name" in error && typeof error.name === "string") { + const name = error.name.trim(); + return name.length > 0 ? name : null; + } + + return null; +} + +function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { + return (parts ?? []) + .flatMap((part) => { + if (!part || typeof part !== "object") { + return []; + } + if (!("type" in part) || part.type !== "text") { + return []; + } + if (!("text" in part) || typeof part.text !== "string") { + return []; + } + return [part.text]; + }) + .join("") + .trim(); +} + interface SharedOpenCodeTextGenerationServerState { server: OpenCodeServerProcess | null; binaryPath: string | null; @@ -245,17 +288,18 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { ...(input.modelSelection.options?.variant ? { variant: input.modelSelection.options.variant } : {}), - format: { - type: "json_schema", - schema: toJsonSchemaObject(input.outputSchemaJson) as Record, - }, parts: [{ type: "text", text: input.prompt }, ...fileParts], }); - const structured = result.data?.info?.structured; - if (structured === undefined) { - throw new Error("OpenCode returned no structured output."); + const info = result.data?.info; + const errorMessage = getOpenCodePromptErrorMessage(info?.error); + if (errorMessage) { + throw new Error(errorMessage); + } + const rawText = getOpenCodeTextResponse(result.data?.parts); + if (rawText.length === 0) { + throw new Error("OpenCode returned empty output."); } - return structured; + return rawText; }, catch: (cause) => new TextGenerationError({ @@ -266,7 +310,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { }), }); - const structuredOutput = + const rawOutput = settings.serverUrl.length > 0 ? yield* runAgainstServer({ url: settings.serverUrl }) : yield* Effect.acquireUseRelease( @@ -278,7 +322,9 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { releaseSharedServer, ); - return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( + return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( + extractJsonObject(rawOutput), + ).pipe( Effect.catchTag("SchemaError", (cause) => Effect.fail( new TextGenerationError({ diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 4a7931c74b..15015e8cda 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -30,6 +30,54 @@ export function limitSection(value: string, maxChars: number): string { return `${truncated}\n\n[truncated]`; } +export function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ export function sanitizeCommitSubject(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; From 8dbcf92a0d125050988474f258df3e55c538efec Mon Sep 17 00:00:00 2001 From: reasv <7143787+reasv@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:13:20 +0200 Subject: [PATCH 05/13] fix(server): prevent probeClaudeCapabilities from wasting API requests (#2192) Co-authored-by: Claude Opus 4.6 Co-authored-by: Julius Marminge --- .../src/provider/Layers/ClaudeProvider.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 5274f84285..7c8a4c27a6 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -14,6 +14,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, type SlashCommand as ClaudeSlashCommand, + type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -480,13 +481,24 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } +function waitForAbortSignal(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. * - * The prompt is never sent to the Anthropic API — we abort immediately - * after the local initialization phase completes. This gives us the - * user's subscription type without incurring any token cost. + * We pass a never-yielding AsyncIterable as the prompt so that no user + * message is ever written to the subprocess stdin. This means the Claude + * Code subprocess completes its local initialization IPC (returning + * account info and slash commands) but never starts an API request to + * Anthropic. We read the init data and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include * subscription type information. @@ -495,12 +507,16 @@ const probeClaudeCapabilities = (binaryPath: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { const q = claudeQuery({ - prompt: ".", + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), options: { persistSession: false, pathToClaudeCodeExecutable: binaryPath, abortController: abort, - maxTurns: 0, settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, From 66c326b8c424ca1e3702232a4fe5a06f6ba2a525 Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Sun, 19 Apr 2026 20:43:40 -0400 Subject: [PATCH 06/13] Redesign model picker with favorites and search (#2153) Co-authored-by: Julius Marminge --- apps/desktop/src/clientPersistence.test.ts | 1 + apps/server/src/keybindings.test.ts | 3 + apps/server/src/keybindings.ts | 7 + .../src/provider/opencodeRuntime.test.ts | 59 +- apps/server/src/provider/opencodeRuntime.ts | 3 +- apps/web/src/components/AppSidebarLayout.tsx | 32 +- apps/web/src/components/ChatView.browser.tsx | 237 ++++++ apps/web/src/components/ChatView.tsx | 10 + .../src/components/CommandPalette.logic.ts | 34 +- apps/web/src/components/Icons.tsx | 43 +- apps/web/src/components/Sidebar.tsx | 145 +--- apps/web/src/components/chat/ChatComposer.tsx | 110 +-- .../components/chat/ComposerCommandMenu.tsx | 14 - apps/web/src/components/chat/ModelListRow.tsx | 104 +++ .../components/chat/ModelPickerContent.tsx | 519 ++++++++++++ .../components/chat/ModelPickerSidebar.tsx | 210 +++++ .../chat/ProviderModelPicker.browser.tsx | 751 ++++++++++++++++-- .../components/chat/ProviderModelPicker.tsx | 277 +++---- .../chat/modelPickerModelHighlights.ts | 13 + .../components/chat/modelPickerSearch.test.ts | 109 +++ .../src/components/chat/modelPickerSearch.ts | 83 ++ .../src/components/chat/providerIconUtils.ts | 52 ++ apps/web/src/components/ui/autocomplete.tsx | 2 +- apps/web/src/components/ui/combobox.tsx | 16 +- apps/web/src/components/ui/scroll-area.tsx | 7 +- apps/web/src/composer-logic.test.ts | 15 +- apps/web/src/composer-logic.ts | 20 +- apps/web/src/hooks/useSettings.ts | 7 +- apps/web/src/index.css | 22 + apps/web/src/keybindings.test.ts | 60 ++ apps/web/src/keybindings.ts | 60 +- apps/web/src/localApi.test.ts | 2 + apps/web/src/modelPickerOpenState.ts | 17 + apps/web/src/modelSelection.ts | 9 +- apps/web/src/session-logic.ts | 6 +- apps/web/src/shortcutModifierState.test.ts | 146 ++++ apps/web/src/shortcutModifierState.ts | 96 +++ packages/contracts/src/keybindings.test.ts | 12 + packages/contracts/src/keybindings.ts | 21 + packages/contracts/src/server.ts | 2 + packages/contracts/src/settings.ts | 31 +- 41 files changed, 2832 insertions(+), 535 deletions(-) create mode 100644 apps/web/src/components/chat/ModelListRow.tsx create mode 100644 apps/web/src/components/chat/ModelPickerContent.tsx create mode 100644 apps/web/src/components/chat/ModelPickerSidebar.tsx create mode 100644 apps/web/src/components/chat/modelPickerModelHighlights.ts create mode 100644 apps/web/src/components/chat/modelPickerSearch.test.ts create mode 100644 apps/web/src/components/chat/modelPickerSearch.ts create mode 100644 apps/web/src/components/chat/providerIconUtils.ts create mode 100644 apps/web/src/modelPickerOpenState.ts create mode 100644 apps/web/src/shortcutModifierState.test.ts create mode 100644 apps/web/src/shortcutModifierState.ts diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 27f1e1d91a..19f9b09f9f 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, diffWordWrap: true, + favorites: [], sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 15edd4295d..bbb7e1b430 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -192,6 +192,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 07ae9156c1..9689254c17 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -13,6 +13,7 @@ import { KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, + MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, @@ -65,6 +66,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, @@ -72,6 +74,11 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ key: `mod+${index + 1}`, command, })), + ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + when: "modelPickerOpen", + })), ]; function normalizeKeyToken(token: string): string { diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts index 0ea63f8d53..0e7024efc9 100644 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -1,37 +1,36 @@ -import assert from "node:assert/strict"; +import { describe, expect, it } from "vitest"; -import { describe, it, vi } from "vitest"; +import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts"; -const childProcessMock = vi.hoisted(() => ({ - execFileSync: vi.fn((command: string, args: ReadonlyArray) => { - if (command === "which" && args[0] === "opencode") { - return "/opt/homebrew/bin/opencode\n"; - } - return ""; - }), - spawn: vi.fn(), -})); - -vi.mock("node:child_process", () => childProcessMock); - -describe("resolveOpenCodeBinaryPath", () => { - it("returns absolute binary paths without PATH lookup", async () => { - const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); - - assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); - assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); - }); - - it("resolves command names through PATH", async () => { - const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); +describe("flattenOpenCodeModels", () => { + it("keeps the canonical model name separate from the subprovider label", () => { + const models = flattenOpenCodeModels({ + providerList: { + connected: ["github-copilot"], + all: [ + { + id: "github-copilot", + name: "GitHub Copilot", + models: { + "claude-opus-4.5": { + id: "claude-opus-4.5", + name: "Claude Opus 4.5", + variants: {}, + }, + }, + }, + ], + }, + agents: [], + } as unknown as Parameters[0]); - assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); - assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ - "which", - ["opencode"], + expect(models).toEqual([ { - encoding: "utf8", - timeout: 3_000, + slug: "github-copilot/claude-opus-4.5", + name: "Claude Opus 4.5", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: DEFAULT_OPENCODE_MODEL_CAPABILITIES, }, ]); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 4778f6eac9..91a855bce6 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -558,7 +558,8 @@ export function flattenOpenCodeModels( for (const model of Object.values(provider.models)) { models.push({ slug: toOpenCodeModelSlug(provider.id, model.id), - name: `${provider.name} · ${model.name}`, + name: model.name, + subProvider: provider.name, isCustom: false, capabilities: openCodeCapabilitiesForModel({ providerID: provider.id, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index a2b27bb1e9..b1ce57235a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -3,14 +3,39 @@ import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { + clearShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "../shortcutModifierState"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; - export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowKeyUp = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowBlur = () => { + clearShortcutModifierState(); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + window.addEventListener("keyup", onWindowKeyUp, true); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + window.removeEventListener("keyup", onWindowKeyUp, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -18,8 +43,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { } const unsubscribe = onMenuAction((action) => { - if (action !== "open-settings") return; - void navigate({ to: "/settings" }); + if (action === "open-settings") { + void navigate({ to: "/settings" }); + } }); return () => { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 41f627332e..6857a51b51 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1384,6 +1384,18 @@ function dispatchChatNewShortcut(): void { ); } +function releaseModShortcut(key?: string): void { + window.dispatchEvent( + new KeyboardEvent("keyup", { + key: key ?? (isMacPlatform(navigator.platform) ? "Meta" : "Control"), + metaKey: false, + ctrlKey: false, + bubbles: true, + cancelable: true, + }), + ); +} + async function triggerChatNewShortcutUntilPath( router: ReturnType, predicate: (pathname: string) => boolean, @@ -4006,6 +4018,29 @@ describe("ChatView timeline estimator parity (full app)", () => { node: { type: "identifier", name: "terminalFocus" }, }, }, + { + command: "thread.jump.1", + shortcut: { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, ], }; }, @@ -5520,6 +5555,208 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the model picker when selecting /model", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-model-command-target" as MessageId, + targetText: "model command thread", + }), + }); + + try { + await waitForComposerEditor(); + await page.getByTestId("composer-editor").fill("/mod"); + + const menuItem = await waitForComposerMenuItem("slash:model"); + await menuItem.click(); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + expect(findComposerProviderModelPicker()?.textContent).not.toContain("/model"); + }); + + await new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + + await vi.waitFor(() => { + const searchInput = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInput).not.toBeNull(); + expect(document.activeElement).toBe(searchInput); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("toggles the model picker and shows jump keys immediately from the shortcut", async () => { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-model-picker-shortcut-target" as MessageId, + targetText: "model picker shortcut thread", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID + ? Object.assign({}, project, { + defaultModelSelection: { provider: "codex", model: "gpt-5.4" }, + }) + : project, + ), + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? Object.assign({}, thread, { + modelSelection: { provider: "codex", model: "gpt-5.4" }, + }) + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "modelPicker.toggle", + shortcut: { + key: "m", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + modKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + { + command: "thread.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + }, + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, + ], + providers: [ + { + ...nextFixture.serverConfig.providers[0]!, + provider: "codex", + models: [ + { + slug: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + isCustom: false, + capabilities: { + supportsFastMode: true, + supportsThinkingToggle: false, + reasoningEffortLevels: [], + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + supportsFastMode: true, + supportsThinkingToggle: false, + reasoningEffortLevels: [], + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + supportsFastMode: true, + supportsThinkingToggle: false, + reasoningEffortLevels: [], + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, + ], + }, + ], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + await waitForComposerEditor(); + + const initialPath = mounted.router.state.location.pathname; + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + }); + + const jumpLabel = isMacPlatform(navigator.platform) ? "⌃1" : "Ctrl+1"; + await vi.waitFor(() => { + expect( + Array.from( + document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), + ).some((element) => element.textContent?.trim() === jumpLabel), + ).toBe(true); + }); + expect(mounted.router.state.location.pathname).toBe(initialPath); + + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).toBeNull(); + }); + } finally { + releaseModShortcut("Control"); + await mounted.cleanup(); + } + }); + it("shows a tooltip with the skill description when hovering a skill pill", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 47dad09ea2..535c0d9fca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2233,6 +2233,7 @@ export default function ChatView(props: ChatViewProps) { const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; const command = resolveShortcutCommand(event, keybindings, { @@ -2282,6 +2283,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "modelPicker.toggle") { + event.preventDefault(); + event.stopPropagation(); + composerRef.current?.toggleModelPicker(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -3341,6 +3349,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadActivities={activeThread?.activities} resolvedTheme={resolvedTheme} settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalState.terminalOpen)} gitCwd={gitCwd} promptRef={promptRef} composerImagesRef={composerImagesRef} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 866db58fb4..450f678dd5 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -151,22 +151,26 @@ export function buildThreadActionItems { - await input.runThread(thread); + return Object.assign( + { + kind: "action" as const, + value: `thread:${thread.id}`, + searchTerms: [thread.title, projectTitle ?? ``, thread.branch ?? ``], + title: thread.title, + description: descriptionParts.join(` · `), + timestamp: formatRelativeTimeLabel( + thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ), + icon: input.icon, + }, + leadingContent ? { titleLeadingContent: leadingContent } : {}, + trailingContent ? { titleTrailingContent: trailingContent } : {}, + { + run: async () => { + await input.runThread(thread); + }, }, - }; + ); }); } diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 9a7ac5fbb9..98bc509a6f 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,4 +1,5 @@ import { type SVGProps, useId } from "react"; +import { cn } from "~/lib/utils"; export type Icon = React.FC>; @@ -14,8 +15,12 @@ export const GitHubIcon: Icon = (props) => ( ); -export const CursorIcon: Icon = (props) => ( - +export const CursorIcon: Icon = ({ className, ...props }) => ( + ); @@ -290,18 +295,25 @@ export const Zed: Icon = (props) => { ); }; -export const OpenAI: Icon = (props) => ( - +export const OpenAI: Icon = ({ className, ...props }) => ( + ); -export const ClaudeAI: Icon = (props) => ( - - +export const ClaudeAI: Icon = ({ className, ...props }) => ( + + ); @@ -565,3 +577,14 @@ export const OpenCodeIcon: Icon = (props) => ( ); + +export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( + + + +); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8272779399..8335042c11 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -75,11 +75,13 @@ import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, shortcutLabelForCommand, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, threadJumpCommandForIndex, threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; +import { useModelPickerOpen } from "../modelPickerOpenState"; +import { useShortcutModifierState } from "../shortcutModifierState"; import { useGitStatus } from "../lib/gitStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -199,24 +201,6 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; -function threadJumpLabelMapsEqual( - left: ReadonlyMap, - right: ReadonlyMap, -): boolean { - if (left === right) { - return true; - } - if (left.size !== right.size) { - return false; - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false; - } - } - return true; -} - function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, groupedProjectCount: number, @@ -2715,6 +2699,8 @@ export default function Sidebar() { const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const platform = navigator.platform; + const shortcutModifiers = useShortcutModifierState(); + const modelPickerOpen = useModelPickerOpen(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); @@ -2822,8 +2808,9 @@ export default function Sidebar() { routeThreadRef, ).terminalOpen : false, + modelPickerOpen, }), - [routeThreadRef], + [modelPickerOpen, routeThreadRef], ); const newThreadShortcutLabelOptions = useMemo( () => ({ @@ -3016,12 +3003,37 @@ export default function Sidebar() { () => [...threadJumpCommandByKey.keys()], [threadJumpCommandByKey], ); - const [threadJumpLabelByKey, setThreadJumpLabelByKey] = - useState>(EMPTY_THREAD_JUMP_LABELS); - const threadJumpLabelsRef = useRef>(EMPTY_THREAD_JUMP_LABELS); - threadJumpLabelsRef.current = threadJumpLabelByKey; - const showThreadJumpHintsRef = useRef(showThreadJumpHints); - showThreadJumpHintsRef.current = showThreadJumpHints; + const sidebarShortcutContext = useMemo( + () => ({ + terminalFocus: false, + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + modelPickerOpen, + }), + [modelPickerOpen, routeThreadRef], + ); + const threadJumpLabelByKey = useMemo( + () => + buildThreadJumpLabelMap({ + keybindings, + platform, + terminalOpen: sidebarShortcutContext.terminalOpen, + threadJumpCommandByKey, + }), + [keybindings, platform, sidebarShortcutContext.terminalOpen, threadJumpCommandByKey], + ); + const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { + platform, + context: sidebarShortcutContext, + }, + ); const visibleThreadJumpLabelByKey = showThreadJumpHints ? threadJumpLabelByKey : EMPTY_THREAD_JUMP_LABELS; @@ -3052,52 +3064,12 @@ export default function Sidebar() { }, [prewarmedSidebarThreadRefs]); useEffect(() => { - const clearThreadJumpHints = () => { - setThreadJumpLabelByKey((current) => - current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, - ); - updateThreadJumpHintsVisibility(false); - }; - const shouldIgnoreThreadJumpHintUpdate = (event: globalThis.KeyboardEvent) => - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - event.key !== "Meta" && - event.key !== "Control" && - event.key !== "Alt" && - event.key !== "Shift" && - !showThreadJumpHintsRef.current && - threadJumpLabelsRef.current === EMPTY_THREAD_JUMP_LABELS; + updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); + }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); + useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - if ( - showThreadJumpHintsRef.current || - threadJumpLabelsRef.current !== EMPTY_THREAD_JUMP_LABELS - ) { - clearThreadJumpHints(); - } - } else { - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - } if (event.defaultPrevented || event.repeat) { return; @@ -3147,43 +3119,10 @@ export default function Sidebar() { navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; - const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - if (shouldIgnoreThreadJumpHintUpdate(event)) { - return; - } - const shortcutContext = getCurrentSidebarShortcutContext(); - const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { - platform, - context: shortcutContext, - }); - if (!shouldShowHints) { - clearThreadJumpHints(); - return; - } - setThreadJumpLabelByKey((current) => { - const nextLabelMap = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: shortcutContext.terminalOpen, - threadJumpCommandByKey, - }); - return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; - }); - updateThreadJumpHintsVisibility(true); - }; - - const onWindowBlur = () => { - clearThreadJumpHints(); - }; - window.addEventListener("keydown", onWindowKeyDown); - window.addEventListener("keyup", onWindowKeyUp); - window.addEventListener("blur", onWindowBlur); return () => { window.removeEventListener("keydown", onWindowKeyDown); - window.removeEventListener("keyup", onWindowKeyUp); - window.removeEventListener("blur", onWindowBlur); }; }, [ getCurrentSidebarShortcutContext, @@ -3193,9 +3132,7 @@ export default function Sidebar() { platform, routeThreadKey, sidebarThreadByKey, - threadJumpCommandByKey, threadJumpThreadKeys, - updateThreadJumpHintsVisibility, ]); useEffect(() => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4cc2c85cae..3d3b081af9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -6,6 +6,7 @@ import type { ProviderApprovalDecision, ProviderInteractionMode, ProviderKind, + ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, @@ -59,7 +60,7 @@ import { shouldUseCompactComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./ProviderModelPicker"; +import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; @@ -317,6 +318,9 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; + openModelPicker: () => void; + toggleModelPicker: () => void; + isModelPickerOpen: () => boolean; readSnapshot: () => { value: string; cursor: number; @@ -410,6 +414,8 @@ export interface ChatComposerProps { // Misc resolvedTheme: "light" | "dark"; settings: UnifiedSettings; + keybindings: ResolvedKeybindingsConfig; + terminalOpen: boolean; gitCwd: string | null; // Refs the parent needs kept in sync @@ -497,6 +503,8 @@ export const ChatComposer = memo( activeThreadActivities, resolvedTheme, settings, + keybindings, + terminalOpen, gitCwd, promptRef, composerImagesRef, @@ -622,23 +630,6 @@ export const ChatComposer = memo( ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); - const searchableModelOptions = useMemo( - () => - AVAILABLE_PROVIDER_OPTIONS.filter( - (option) => lockedProvider === null || option.value === lockedProvider, - ).flatMap((option) => - modelOptionsByProvider[option.value].map(({ slug, name }) => ({ - provider: option.value, - providerLabel: option.label, - slug, - name, - searchSlug: slug.toLowerCase(), - searchName: name.toLowerCase(), - searchProvider: option.label.toLowerCase(), - })), - ), - [lockedProvider, modelOptionsByProvider], - ); // ------------------------------------------------------------------ // Context window @@ -664,6 +655,7 @@ export const ChatComposer = memo( const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); + const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); // ------------------------------------------------------------------ // Refs @@ -782,31 +774,8 @@ export const ChatComposer = memo( (skill.scope ? `${skill.scope} skill` : "Run provider skill"), })); } - return searchableModelOptions - .filter(({ searchSlug, searchName, searchProvider }) => { - const query = composerTrigger.query.trim().toLowerCase(); - if (!query) return true; - return ( - searchSlug.includes(query) || - searchName.includes(query) || - searchProvider.includes(query) - ); - }) - .map(({ provider, providerLabel, slug, name }) => ({ - id: `model:${provider}:${slug}`, - type: "model", - provider, - model: slug, - label: name, - description: `${providerLabel} · ${slug}`, - })); - }, [ - composerTrigger, - searchableModelOptions, - selectedProvider, - selectedProviderStatus, - workspaceEntries, - ]); + return []; + }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1276,7 +1245,7 @@ export const ChatComposer = memo( rangeStart: number, rangeEnd: number, replacement: string, - options?: { expectedText?: string }, + options?: { expectedText?: string; focusEditorAfterReplace?: boolean }, ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); @@ -1305,9 +1274,11 @@ export const ChatComposer = memo( } setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(nextCursor); - }); + if (options?.focusEditorAfterReplace !== false) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt(nextCursor); + }); + } return true; }, [ @@ -1377,20 +1348,13 @@ export const ChatComposer = memo( } if (item.type === "slash-command") { if (item.command === "model") { - const replacement = "/model "; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); if (applied) { setComposerHighlightedItemId(null); + setIsComposerModelPickerOpen(true); } return; } @@ -1439,20 +1403,8 @@ export const ChatComposer = memo( } return; } - onProviderModelSelect(item.provider, item.model); - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - }); - if (applied) { - setComposerHighlightedItemId(null); - } }, - [ - applyPromptReplacement, - handleInteractionModeChange, - onProviderModelSelect, - resolveActiveComposerTrigger, - ], + [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], ); const onComposerMenuItemHighlighted = useCallback( @@ -1633,6 +1585,13 @@ export const ChatComposer = memo( focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + openModelPicker: () => { + setIsComposerModelPickerOpen(true); + }, + toggleModelPicker: () => { + setIsComposerModelPickerOpen((open) => !open); + }, + isModelPickerOpen: () => isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); }, @@ -1710,6 +1669,7 @@ export const ChatComposer = memo( promptRef, composerImagesRef, composerTerminalContextsRef, + isComposerModelPickerOpen, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, @@ -1925,13 +1885,19 @@ export const ChatComposer = memo( model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} providers={providerStatuses} + keybindings={keybindings} modelOptionsByProvider={modelOptionsByProvider} + terminalOpen={terminalOpen} + open={isComposerModelPickerOpen} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: composerProviderState.modelPickerIconClassName, } : {})} + onOpenChange={(open) => { + setIsComposerModelPickerOpen(open); + }} onProviderModelChange={onProviderModelSelect} /> diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index de7cf2b2b8..5d13e6593b 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -10,7 +10,6 @@ import { memo, useLayoutEffect, useMemo, useRef } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { formatProviderSkillInstallSource } from "~/providerSkillPresentation"; import { cn } from "~/lib/utils"; -import { Badge } from "../ui/badge"; import { Command, CommandGroup, @@ -45,14 +44,6 @@ export type ComposerCommandItem = label: string; description: string; } - | { - id: string; - type: "model"; - provider: ProviderKind; - model: string; - label: string; - description: string; - } | { id: string; type: "skill"; @@ -255,11 +246,6 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { ) : null} - {props.item.type === "model" ? ( - - model - - ) : null} {props.item.label} diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx new file mode 100644 index 0000000000..6cd097ad1f --- /dev/null +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -0,0 +1,104 @@ +import { type ProviderKind } from "@t3tools/contracts"; +import { memo } from "react"; +import { StarIcon } from "lucide-react"; +import { + getDisplayModelName, + getProviderLabel, + getTriggerDisplayModelLabel, + type ModelEsque, + PROVIDER_ICON_BY_PROVIDER, +} from "./providerIconUtils"; +import { ComboboxItem } from "../ui/combobox"; +import { Kbd } from "../ui/kbd"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; + +export const ModelListRow = memo(function ModelListRow(props: { + index: number; + model: ModelEsque; + provider: ProviderKind; + isFavorite: boolean; + showProvider: boolean; + preferShortName?: boolean; + useTriggerLabel?: boolean; + showNewBadge?: boolean; + jumpLabel?: string | null; + onToggleFavorite: () => void; +}) { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + + return ( + + + { + event.stopPropagation(); + props.onToggleFavorite(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} + > + + + } + /> + + {props.isFavorite ? "Remove from favorites" : "Add to favorites"} + + + +
+
+
+ + {props.useTriggerLabel + ? getTriggerDisplayModelLabel(props.model) + : getDisplayModelName( + props.model, + props.preferShortName ? { preferShortName: true } : undefined, + )} + + {props.showNewBadge ? ( + + New + + ) : null} +
+ {props.jumpLabel ? ( + + {props.jumpLabel} + + ) : null} +
+ {props.showProvider && ( +
+ + + {getProviderLabel(props.provider, props.model)} + +
+ )} +
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx new file mode 100644 index 0000000000..82720425ef --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -0,0 +1,519 @@ +import { + type ProviderKind, + PROVIDER_DISPLAY_NAMES, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { resolveSelectableModel } from "@t3tools/shared/model"; +import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { SearchIcon } from "lucide-react"; +import { ModelListRow } from "./ModelListRow"; +import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { isModelPickerNewModel } from "./modelPickerModelHighlights"; +import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; +import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { + modelPickerJumpCommandForIndex, + modelPickerJumpIndexFromCommand, + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../../keybindings"; +import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { cn } from "~/lib/utils"; +import { TooltipProvider } from "../ui/tooltip"; + +type ModelPickerItem = { + slug: string; + name: string; + shortName?: string; + subProvider?: string; + provider: ProviderKind; +}; + +const EMPTY_MODEL_JUMP_LABELS = new Map(); + +export const ModelPickerContent = memo(function ModelPickerContent(props: { + provider: ProviderKind; + model: string; + lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; + keybindings?: ResolvedKeybindingsConfig; + modelOptionsByProvider: Record>; + terminalOpen: boolean; + onRequestClose?: () => void; + onProviderModelChange: (provider: ProviderKind, model: string) => void; +}) { + const { keybindings: providedKeybindings, modelOptionsByProvider, onProviderModelChange } = props; + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const listRegionRef = useRef(null); + const highlightedModelKeyRef = useRef(null); + const favorites = useSettings((s) => s.favorites ?? []); + const [selectedProvider, setSelectedProvider] = useState(() => { + if (props.lockedProvider !== null) { + return props.lockedProvider; + } + return favorites.length > 0 ? "favorites" : props.provider; + }); + const keybindings = useMemo( + () => providedKeybindings ?? [], + [providedKeybindings], + ); + const { updateSettings } = useUpdateSettings(); + + const focusSearchInput = useCallback(() => { + searchInputRef.current?.focus({ preventScroll: true }); + }, []); + + const handleSelectProvider = useCallback( + (provider: ProviderKind | "favorites") => { + setSelectedProvider(provider); + window.requestAnimationFrame(() => { + focusSearchInput(); + }); + }, + [focusSearchInput], + ); + + useLayoutEffect(() => { + focusSearchInput(); + const frame = window.requestAnimationFrame(() => { + focusSearchInput(); + }); + const timeout = window.setTimeout(() => { + focusSearchInput(); + }, 0); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timeout); + }; + }, [focusSearchInput]); + + // Create a Set for efficient lookup + const favoritesSet = useMemo(() => { + return new Set(favorites.map((fav) => `${fav.provider}:${fav.model}`)); + }, [favorites]); + const favoriteOrder = useMemo(() => { + return new Map( + favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), + ); + }, [favorites]); + + const readyProviderSet = useMemo(() => { + if (!props.providers || props.providers.length === 0) { + return null; + } + return new Set( + props.providers + .filter((provider) => provider.status === "ready") + .map((provider) => provider.provider), + ); + }, [props.providers]); + + // Flatten models into a searchable array + const flatModels = useMemo(() => { + return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { + if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { + return []; + } + return models.map((m) => ({ + slug: m.slug, + name: m.name, + ...(m.shortName ? { shortName: m.shortName } : {}), + ...(m.subProvider ? { subProvider: m.subProvider } : {}), + provider: providerKind as ProviderKind, + })) satisfies Array; + }); + }, [props.modelOptionsByProvider, readyProviderSet]); + + // Filter models based on search query and selected provider + const filteredModels = useMemo(() => { + let result = flatModels; + + // Apply tokenized fuzzy search across the combined provider/model search fields. + if (searchQuery.trim()) { + const rankedMatches = result + .map((model) => ({ + model, + score: scoreModelPickerSearch( + { + ...model, + isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + }, + searchQuery, + ), + isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + tieBreaker: buildModelPickerSearchText(model), + })) + .filter( + ( + rankedModel, + ): rankedModel is { + model: ModelPickerItem; + score: number; + isFavorite: boolean; + tieBreaker: string; + } => rankedModel.score !== null, + ); + + // When searching, we only respect locked provider, ignoring sidebar selection + if (props.lockedProvider !== null) { + return rankedMatches + .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider) + .toSorted((a, b) => { + const scoreDelta = a.score - b.score; + if (scoreDelta !== 0) { + return scoreDelta; + } + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } + return a.tieBreaker.localeCompare(b.tieBreaker); + }) + .map((rankedModel) => rankedModel.model); + } + + return rankedMatches + .toSorted((a, b) => { + const scoreDelta = a.score - b.score; + if (scoreDelta !== 0) { + return scoreDelta; + } + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } + return a.tieBreaker.localeCompare(b.tieBreaker); + }) + .map((rankedModel) => rankedModel.model); + } + + // Locked provider mode always shows that provider's models, with favorites first. + if (props.lockedProvider !== null) { + result = result.filter((m) => m.provider === props.lockedProvider); + } else if (selectedProvider === "favorites") { + result = result.filter((m) => favoritesSet.has(`${m.provider}:${m.slug}`)); + } else { + result = result.filter((m) => m.provider === selectedProvider); + } + + return result.toSorted((a, b) => { + const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`); + const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`); + + if (aOrder !== undefined && bOrder !== undefined) { + return aOrder - bOrder; + } + if (aOrder !== undefined) { + return -1; + } + if (bOrder !== undefined) { + return 1; + } + return 0; + }); + }, [ + favoriteOrder, + favoritesSet, + flatModels, + props.lockedProvider, + searchQuery, + selectedProvider, + ]); + + const handleModelSelect = useCallback( + (modelSlug: string, provider: ProviderKind) => { + const resolvedModel = resolveSelectableModel( + provider, + modelSlug, + modelOptionsByProvider[provider], + ); + if (resolvedModel) { + onProviderModelChange(provider, resolvedModel); + } + }, + [modelOptionsByProvider, onProviderModelChange], + ); + + const toggleFavorite = useCallback( + (provider: ProviderKind, model: string) => { + const newFavorites = [...favorites]; + const index = newFavorites.findIndex((f) => f.provider === provider && f.model === model); + if (index >= 0) { + newFavorites.splice(index, 1); + } else { + newFavorites.push({ provider, model }); + } + updateSettings({ favorites: newFavorites }); + }, + [favorites, updateSettings], + ); + + const isLocked = props.lockedProvider !== null; + const isSearching = searchQuery.trim().length > 0; + const showSidebar = !isLocked && !isSearching; + const LockedProviderIcon = + isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; + const modelJumpCommandByKey = useMemo(() => { + const mapping = new Map< + string, + NonNullable> + >(); + for (const [visibleModelIndex, model] of filteredModels.entries()) { + const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(`${model.provider}:${model.slug}`, jumpCommand); + } + return mapping; + }, [filteredModels]); + const modelJumpModelKeys = useMemo( + () => [...modelJumpCommandByKey.keys()], + [modelJumpCommandByKey], + ); + const allModelKeys = useMemo( + (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`), + [flatModels], + ); + const filteredModelKeys = useMemo( + (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`), + [filteredModels], + ); + const filteredModelByKey = useMemo( + (): ReadonlyMap => + new Map(filteredModels.map((model) => [`${model.provider}:${model.slug}`, model] as const)), + [filteredModels], + ); + const modelJumpShortcutContext = useMemo( + () => + ({ + terminalFocus: false, + terminalOpen: props.terminalOpen, + modelPickerOpen: true, + }) as const, + [props.terminalOpen], + ); + const modelJumpLabelByKey = useMemo((): ReadonlyMap => { + if (modelJumpCommandByKey.size === 0) { + return EMPTY_MODEL_JUMP_LABELS; + } + const shortcutLabelOptions = { + platform: navigator.platform, + context: modelJumpShortcutContext, + }; + const mapping = new Map(); + for (const [modelKey, command] of modelJumpCommandByKey) { + const label = shortcutLabelForCommand(keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(modelKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; + }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: modelJumpShortcutContext, + }); + const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetModelKey = modelJumpModelKeys[jumpIndex]; + if (!targetModelKey) { + return; + } + const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string]; + event.preventDefault(); + event.stopPropagation(); + handleModelSelect(slug, provider); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + }; + }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + + useLayoutEffect(() => { + const listRegion = listRegionRef.current; + if (!listRegion) { + return; + } + + let cancelled = false; + let frame = 0; + let nestedFrame = 0; + let timeout = 0; + + const measureScrollArea = () => { + if (cancelled) { + return; + } + const viewport = listRegion.querySelector('[data-slot="scroll-area-viewport"]'); + if (!viewport || viewport.scrollHeight <= viewport.clientHeight) { + return; + } + const originalScrollTop = viewport.scrollTop; + const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; + if (maxScrollTop <= 0) { + return; + } + viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop); + viewport.scrollTop = originalScrollTop; + }; + + queueMicrotask(measureScrollArea); + frame = window.requestAnimationFrame(() => { + measureScrollArea(); + nestedFrame = window.requestAnimationFrame(measureScrollArea); + }); + timeout = window.setTimeout(measureScrollArea, 0); + + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + window.cancelAnimationFrame(nestedFrame); + window.clearTimeout(timeout); + }; + }, [filteredModelKeys]); + + return ( + +
+ {/* Locked provider header (only shown in locked mode) */} + {isLocked && LockedProviderIcon && props.lockedProvider && ( +
+ + + {PROVIDER_DISPLAY_NAMES[props.lockedProvider]} + +
+ )} + + {/* Sidebar (only in unlocked mode) */} + {showSidebar && ( + + )} + + {/* Main content area */} + { + highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; + }} + onValueChange={(modelKey) => { + if (typeof modelKey !== "string") { + return; + } + const [provider, slug] = modelKey.split(":") as [ProviderKind, string]; + handleModelSelect(slug, provider); + }} + > +
+ {/* Search bar */} +
+ } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + props.onRequestClose?.(); + return; + } + if (e.key === "Enter" && highlightedModelKeyRef.current) { + ( + e as typeof e & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + e.preventDefault(); + e.stopPropagation(); + const [provider, slug] = highlightedModelKeyRef.current.split(":") as [ + ProviderKind, + string, + ]; + handleModelSelect(slug, provider); + return; + } + e.stopPropagation(); + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + size="sm" + /> +
+ + {/* Model list */} +
+ + {filteredModelKeys.map((modelKey, index) => { + const model = filteredModelByKey.get(modelKey); + if (!model) { + return null; + } + return ( + toggleFavorite(model.provider, model.slug)} + /> + ); + })} + +
+ + No models found + +
+
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx new file mode 100644 index 0000000000..b4f23cacdd --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -0,0 +1,210 @@ +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { memo } from "react"; +import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; +import { Gemini, GithubCopilotIcon } from "../Icons"; +import { AVAILABLE_PROVIDER_OPTIONS, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; +import { getProviderSnapshot } from "../../providerModels"; + +function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string { + if (!live) { + return `${label} — waiting for provider status…`; + } + if (live.status === "ready") { + return label; + } + const kind = + live.status === "error" + ? "Unavailable" + : live.status === "warning" + ? "Limited" + : live.status === "disabled" + ? "Disabled in settings" + : "Not ready"; + const msg = live.message?.trim(); + return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; +} + +const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; +const SELECTED_INDICATOR_CLASS = + "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.5 -translate-y-1/2 rounded-l-full bg-primary"; +const BADGE_BASE_CLASS = + "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; +const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; +const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; + +/** Opens toward the rail so the list stays readable (not over the model names). */ +const PICKER_TOOLTIP_SIDE = "left" as const; +const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; + +export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { + selectedProvider: ProviderKind | "favorites"; + onSelectProvider: (provider: ProviderKind | "favorites") => void; + providers?: ReadonlyArray; +}) { + const handleProviderClick = (provider: ProviderKind | "favorites") => { + props.onSelectProvider(provider); + }; + + return ( +
+ {/* Favorites section */} +
+
+ {props.selectedProvider === "favorites" &&
} + + handleProviderClick("favorites")} + type="button" + data-model-picker-provider="favorites" + aria-label="Favorites" + > + + + } + /> + + Favorites + + +
+
+ + {/* Provider buttons */} + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const liveProvider = props.providers + ? getProviderSnapshot(props.providers, option.value) + : undefined; + + const isDisabled = !liveProvider || liveProvider.status !== "ready"; + const isSelected = props.selectedProvider === option.value; + const badge = option.pickerSidebarBadge; + + const providerTooltip = isDisabled + ? describeUnavailableProvider(option.label, liveProvider) + : badge === "new" + ? `${option.label} — New` + : option.label; + + const button = ( + + ); + + const trigger = isDisabled ? ( + {button} + ) : ( + button + ); + + return ( +
+ {isSelected &&
} + + + + {providerTooltip} + + +
+ ); + })} + + {/* Gemini button (coming soon) */} + + + + + } + /> + + Gemini — Coming soon + + + {/* Github Copilot button (coming soon) */} + + + + + } + /> + + Github Copilot — Coming soon + + +
+ ); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index abedcd6eeb..f4854dd9a6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,11 +1,70 @@ import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { page } from "vitest/browser"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { page, userEvent } from "vitest/browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { getCustomModelOptionsByProvider } from "../../modelSelection"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { __resetLocalApiForTests } from "../../localApi"; + +// Mock the environments/runtime module to provide a mock primary environment connection +vi.mock("../../environments/runtime", () => { + const primaryConnection = { + kind: "primary" as const, + knownEnvironment: { + id: "environment-local", + label: "Local environment", + source: "manual" as const, + environmentId: EnvironmentId.make("environment-local"), + target: { + httpBaseUrl: "http://localhost:3000", + wsBaseUrl: "ws://localhost:3000", + }, + }, + environmentId: EnvironmentId.make("environment-local"), + client: { + server: { + getConfig: vi.fn(), + updateSettings: vi.fn(), + }, + }, + ensureBootstrapped: async () => undefined, + reconnect: async () => undefined, + dispose: async () => undefined, + }; + + return { + getEnvironmentHttpBaseUrl: () => "http://localhost:3000", + getSavedEnvironmentRecord: () => null, + getSavedEnvironmentRuntimeState: () => null, + hasSavedEnvironmentRegistryHydrated: () => true, + listSavedEnvironmentRecords: () => [], + resetSavedEnvironmentRegistryStoreForTests: vi.fn(), + resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), + resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => + new URL(path, "http://localhost:3000").toString(), + waitForSavedEnvironmentRegistryHydration: async () => undefined, + addSavedEnvironment: vi.fn(), + disconnectSavedEnvironment: vi.fn(), + ensureEnvironmentConnectionBootstrapped: async () => undefined, + getPrimaryEnvironmentConnection: () => primaryConnection, + readEnvironmentConnection: () => primaryConnection, + reconnectSavedEnvironment: vi.fn(), + removeSavedEnvironment: vi.fn(), + requireEnvironmentConnection: () => primaryConnection, + resetEnvironmentServiceForTests: vi.fn(), + startEnvironmentConnectionService: vi.fn(), + subscribeEnvironmentConnections: () => () => {}, + useSavedEnvironmentRegistryStore: ( + selector: (state: { byId: Record }) => unknown, + ) => selector({ byId: {} }), + useSavedEnvironmentRuntimeStore: ( + selector: (state: { byId: Record }) => unknown, + ) => selector({ byId: {} }), + }; +}); function effort(value: string, isDefault = false) { return { @@ -129,6 +188,21 @@ function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { }; } +function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider { + return { + provider: "opencode", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: new Date().toISOString(), + models, + slashCommands: [], + skills: [], + }; +} + async function mountPicker(props: { provider: ProviderKind; model: string; @@ -168,12 +242,40 @@ async function mountPicker(props: { }; } +function getModelPickerListElement() { + const modelPickerList = document.querySelector(".model-picker-list"); + expect(modelPickerList).not.toBeNull(); + return modelPickerList!; +} + +function getModelPickerListText() { + return getModelPickerListElement().textContent ?? ""; +} + +function getVisibleModelNames() { + return Array.from(getModelPickerListElement().querySelectorAll("div.font-medium")) + .map((element) => element.textContent?.replace(/New$/u, "").trim() ?? "") + .filter((text) => text.length > 0); +} + +function getSidebarProviderOrder() { + return Array.from(document.querySelectorAll("[data-model-picker-provider]")).map( + (element) => element.dataset.modelPickerProvider ?? "", + ); +} + describe("ProviderModelPicker", () => { - afterEach(() => { + beforeEach(async () => { + // Reset test environment before each test + await __resetLocalApiForTests(); + }); + + afterEach(async () => { document.body.innerHTML = ""; + await __resetLocalApiForTests(); }); - it("shows provider submenus when provider switching is allowed", async () => { + it("shows provider sidebar in unlocked mode", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -185,16 +287,16 @@ describe("ProviderModelPicker", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Codex"); + expect(text).not.toContain("Codex"); expect(text).toContain("Claude"); - expect(text).not.toContain("Claude Sonnet 4.6"); + expect(text).toContain("Claude Opus 4.6"); }); } finally { await mounted.cleanup(); } }); - it("opens provider submenus with a visible gap from the parent menu", async () => { + it("shows favorites first in the provider sidebar", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -203,43 +305,575 @@ describe("ProviderModelPicker", () => { try { await page.getByRole("button").click(); - const providerTrigger = page.getByRole("menuitem", { name: "Codex" }); - await providerTrigger.hover(); await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("GPT-5 Codex"); + expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ + "favorites", + "codex", + "claudeAgent", + ]); }); + } finally { + await mounted.cleanup(); + } + }); - const providerTriggerElement = Array.from( - document.querySelectorAll('[role="menuitem"]'), - ).find((element) => element.textContent?.includes("Codex")); - if (!providerTriggerElement) { - throw new Error("Expected the Codex provider trigger to be mounted."); - } + it("filters models by selected provider in sidebar", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); - const providerTriggerRect = providerTriggerElement.getBoundingClientRect(); - const modelElement = Array.from( - document.querySelectorAll('[role="menuitemradio"]'), - ).find((element) => element.textContent?.includes("GPT-5 Codex")); - if (!modelElement) { - throw new Error("Expected the submenu model option to be mounted."); - } + try { + await page.getByRole("button").click(); - const submenuPopup = modelElement.closest('[data-slot="menu-sub-content"]'); - if (!(submenuPopup instanceof HTMLElement)) { - throw new Error("Expected submenu popup to be mounted."); - } + // Start with Claude models visible + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).not.toContain("GPT-5 Codex"); + expect(text).toContain("Claude Opus 4.6"); + }); - const submenuRect = submenuPopup.getBoundingClientRect(); + // Click on Codex provider in sidebar + await vi.waitFor(() => { + expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); + }); + await page.getByRole("button", { name: "Codex", exact: true }).click(); - expect(submenuRect.left).toBeGreaterThanOrEqual(providerTriggerRect.right); - expect(submenuRect.left - providerTriggerRect.right).toBeGreaterThanOrEqual(2); + // Now should only show Codex models + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("GPT-5 Codex"); + expect(listText).not.toContain("Claude Opus 4.6"); + }); } finally { await mounted.cleanup(); } }); - it("shows models directly when the provider is locked mid-thread", async () => { + it("focuses the search input after selecting a sidebar provider", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(document.querySelector('[data-model-picker-provider="codex"]')).not.toBeNull(); + }); + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + const searchInput = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInput).not.toBeNull(); + expect(document.activeElement).toBe(searchInput); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows locked provider header and only its models in locked mode", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + favorites: [ + { provider: "codex", model: "gpt-5-codex" }, + { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + ], + }), + ); + + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + // Should show locked provider label + expect(text).toContain("Claude"); + expect(getVisibleModelNames()).toEqual([ + "Claude Sonnet 4.6", + "Claude Opus 4.6", + "Claude Haiku 4.5", + ]); + }); + } finally { + localStorage.removeItem("t3code:client-settings:v1"); + await mounted.cleanup(); + } + }); + + it("falls back to the active provider's first model when props.model belongs to another provider (#1982)", async () => { + const host = document.createElement("div"); + document.body.append(host); + const onProviderModelChange = vi.fn(); + const modelOptionsByProvider = { + claudeAgent: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + codex: [{ slug: "gpt-5-codex", name: "GPT-5 Codex" }], + cursor: [], + opencode: [], + } as const; + const screen = await render( + , + { container: host }, + ); + + try { + const trigger = document.querySelector( + '[data-chat-provider-model-picker="true"]', + ); + expect(trigger).not.toBeNull(); + const label = trigger?.textContent ?? ""; + expect(label).not.toContain("gpt-5-codex"); + expect(label).toContain("Claude Opus 4.6"); + } finally { + await screen.unmount(); + host.remove(); + } + }); + + it("uses the trigger label for locked opencode rows", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.5", + name: "Claude Opus 4.5", + subProvider: "GitHub Copilot", + shortName: "Opus 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + ]; + const mounted = await mountPicker({ + provider: "opencode", + model: "github-copilot/claude-opus-4.5", + lockedProvider: "opencode", + providers, + }); + + try { + await vi.waitFor(() => { + const trigger = document.querySelector( + '[data-chat-provider-model-picker="true"]', + ); + expect(trigger?.textContent).toContain("GitHub Copilot"); + expect(trigger?.textContent).toContain("Opus 4.5"); + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["GitHub Copilot · Opus 4.5"]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("searches models by name in flat list", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + expect(text).not.toContain("GPT-5 Codex"); + }); + + // Find and type in search box + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("claude"); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + expect(text).not.toContain("GPT-5 Codex"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("supports arrow-key navigation in the model picker", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + + const searchInput = page.getByPlaceholder("Search models..."); + await userEvent.click(searchInput); + await userEvent.keyboard("{ArrowDown}"); + await vi.waitFor(() => { + const highlightedItem = document.querySelector( + '[data-slot="combobox-item"][data-highlighted]', + ); + expect(highlightedItem).not.toBeNull(); + expect(highlightedItem?.textContent).toContain("Claude Opus 4.6"); + }); + await userEvent.keyboard("{ArrowDown}"); + await vi.waitFor(() => { + const highlightedItem = document.querySelector( + '[data-slot="combobox-item"][data-highlighted]', + ); + expect(highlightedItem).not.toBeNull(); + expect(highlightedItem?.textContent).toContain("Claude Sonnet 4.6"); + }); + await userEvent.keyboard("{Enter}"); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith( + "claudeAgent", + "claude-sonnet-4-6", + ); + } finally { + await mounted.cleanup(); + } + }); + + it("hides the provider sidebar while searching", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder().length).toBeGreaterThan(0); + }); + + await page.getByPlaceholder("Search models...").fill("cla"); + + await vi.waitFor(() => { + expect(getSidebarProviderOrder()).toEqual([]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("closes the picker when escape is pressed in search", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.click(); + const searchInputElement = document.querySelector( + 'input[placeholder="Search models..."]', + ); + expect(searchInputElement).not.toBeNull(); + searchInputElement!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("searches models by provider name", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + expect(text).not.toContain("GPT-5 Codex"); + }); + + // Search by provider name + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("codex"); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("GPT-5 Codex"); + expect(listText).not.toContain("Claude Opus 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("matches fuzzy multi-token queries across provider and model text", async () => { + const providers: ReadonlyArray = [ + buildCodexProvider([ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + ]; + const mounted = await mountPicker({ + provider: "opencode", + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + await page.getByPlaceholder("Search models...").fill("coplt op"); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("Claude Opus 4.7"); + expect(listText).not.toContain("GPT-5 Codex"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("renders each search result with its own provider branding", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + { + ...TEST_PROVIDERS[1]!, + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }, + ]; + const mounted = await mountPicker({ + provider: "opencode", + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + await page.getByPlaceholder("Search models...").fill("opus"); + + await vi.waitFor(() => { + const listText = getModelPickerListText(); + expect(listText).toContain("OpenCode · GitHub Copilot"); + expect(listText).toContain("Claude"); + expect(listText).not.toContain("OpenCodeClaude Opus 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("toggles favorite stars when clicked", async () => { + localStorage.removeItem("t3code:client-settings:v1"); + + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + }); + + const getFirstStarButton = () => { + const starButton = document.querySelector( + 'button[aria-label*="favorites"]', + ); + expect(starButton).not.toBeNull(); + return starButton!; + }; + + const firstStar = getFirstStarButton(); + const initialAriaLabel = firstStar.getAttribute("aria-label"); + expect( + initialAriaLabel === "Add to favorites" || initialAriaLabel === "Remove from favorites", + ).toBe(true); + + await page.getByRole("button", { name: initialAriaLabel! }).first().click(); + + const expectedAriaLabel = + initialAriaLabel === "Add to favorites" ? "Remove from favorites" : "Add to favorites"; + + await vi.waitFor(() => { + expect(getFirstStarButton().getAttribute("aria-label")).toBe(expectedAriaLabel); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("does not duplicate favorited models across favorites and all models sections", async () => { + localStorage.removeItem("t3code:client-settings:v1"); + + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude Opus 4.6"); + }); + + const favoriteButton = page.getByRole("button", { + name: "Add to favorites", + }); + await favoriteButton.first().click(); + + await vi.waitFor(async () => { + const favoritedModelRows = Array.from( + getModelPickerListElement().querySelectorAll("div.font-medium"), + ).filter((element) => element.textContent?.trim() === "Claude Opus 4.6"); + expect(favoritedModelRows.length).toBe(1); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("shows favorited models first within the selected provider list", async () => { + localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + favorites: [{ provider: "codex", model: "gpt-5.3-codex" }], + }), + ); + + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames().slice(0, 2)).toEqual(["GPT-5.3 Codex", "GPT-5 Codex"]); + }); + } finally { + await mounted.cleanup(); + localStorage.removeItem("t3code:client-settings:v1"); + } + }); + + it("dispatches callback with correct provider and model when selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -252,15 +886,23 @@ describe("ProviderModelPicker", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Claude Sonnet 4.6"); - expect(text).toContain("Claude Haiku 4.5"); - expect(text).not.toContain("Codex"); }); + + // Click on a model + const modelRow = page.getByText("Claude Sonnet 4.6").first(); + await modelRow.click(); + + // Verify callback was called with correct values + expect(mounted.onProviderModelChange).toHaveBeenCalledWith( + "claudeAgent", + "claude-sonnet-4-6", + ); } finally { await mounted.cleanup(); } }); - it("only shows codex spark when the server reports it for the account", async () => { + it("only shows codex spark when the server reports it", async () => { const providersWithoutSpark: ReadonlyArray = [ buildCodexProvider([ { @@ -309,15 +951,14 @@ describe("ProviderModelPicker", () => { ]; const hidden = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", + provider: "codex", + model: "gpt-5.3-codex", lockedProvider: null, providers: providersWithoutSpark, }); try { await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); await vi.waitFor(() => { const text = document.body.textContent ?? ""; @@ -329,15 +970,14 @@ describe("ProviderModelPicker", () => { } const visible = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", + provider: "codex", + model: "gpt-5.3-codex", lockedProvider: null, providers: providersWithSpark, }); try { await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); await vi.waitFor(() => { expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); @@ -347,27 +987,7 @@ describe("ProviderModelPicker", () => { } }); - it("dispatches the canonical slug when a model is selected", async () => { - const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: "claudeAgent", - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click(); - - expect(mounted.onProviderModelChange).toHaveBeenCalledWith( - "claudeAgent", - "claude-sonnet-4-6", - ); - } finally { - await mounted.cleanup(); - } - }); - - it("shows disabled providers as non-selectable entries", async () => { + it("shows disabled providers grayed out in sidebar", async () => { const disabledProviders = TEST_PROVIDERS.slice(); const claudeIndex = disabledProviders.findIndex( (provider) => provider.provider === "claudeAgent", @@ -380,6 +1000,7 @@ describe("ProviderModelPicker", () => { status: "disabled", }; } + const mounted = await mountPicker({ provider: "codex", model: "gpt-5-codex", @@ -392,9 +1013,9 @@ describe("ProviderModelPicker", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Claude"); - expect(text).toContain("Disabled"); - expect(text).not.toContain("Claude Sonnet 4.6"); + expect(text).toContain("GPT-5 Codex"); + // Disabled provider should not have its models shown + expect(text).not.toContain("Claude Opus 4.6"); }); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 8b20237a83..4f4140f834 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,87 +1,78 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { resolveSelectableModel } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { + type ProviderKind, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { memo, useEffect, useState } from "react"; import type { VariantProps } from "class-variance-authority"; -import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; -import { - Menu, - MenuGroup, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuSub, - MenuSubPopup, - MenuSubTrigger, - MenuTrigger, -} from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { getProviderSnapshot } from "../../providerModels"; - -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available; -} - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - claudeAgent: ClaudeAI, - opencode: OpenCodeIcon, - cursor: CursorIcon, -}; - -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const; - -function providerIconClassName( - provider: ProviderKind | ProviderPickerKind, - fallbackClassName: string, -): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; -} +import { ModelPickerContent } from "./ModelPickerContent"; +import { + ModelEsque, + PROVIDER_ICON_BY_PROVIDER, + getTriggerDisplayModelLabel, + getTriggerDisplayModelName, +} from "./providerIconUtils"; +import { setModelPickerOpen } from "../../modelPickerOpenState"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; - modelOptionsByProvider: Record>; + keybindings?: ResolvedKeybindingsConfig; + modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; + terminalOpen?: boolean; + open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + onOpenChange?: (open: boolean) => void; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + // If the current slug belongs to a different provider (for example after a provider + // switch or disable), prefer the active provider's first option so the trigger icon + // and label stay in sync instead of showing a stale foreign slug. + const selectedModel = + selectedProviderOptions.find((option) => option.slug === props.model) ?? + selectedProviderOptions[0]; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; - const handleModelChange = (provider: ProviderKind, value: string) => { + const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; + const triggerSubtitle = selectedModel?.subProvider; + const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + + const setIsMenuOpen = (open: boolean) => { + props.onOpenChange?.(open); + if (props.open === undefined) { + setUncontrolledIsMenuOpen(open); + } + }; + + useEffect(() => { + setModelPickerOpen(isMenuOpen); + return () => { + setModelPickerOpen(false); + }; + }, [isMenuOpen]); + + const handleProviderModelChange = (provider: ProviderKind, model: string) => { if (props.disabled) return; - if (!value) return; - const resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); - if (!resolvedModel) return; - props.onProviderModelChange(provider, resolvedModel); + props.onProviderModelChange(provider, model); setIsMenuOpen(false); }; return ( - { if (props.disabled) { @@ -91,7 +82,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(open); }} > - - - {props.lockedProvider !== null ? ( - - handleModelChange(props.lockedProvider!, value)} - > - {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - ) : ( - <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - const liveProvider = props.providers - ? getProviderSnapshot(props.providers, option.value) - : undefined; - if (liveProvider && liveProvider.status !== "ready") { - const unavailableLabel = !liveProvider.enabled - ? "Disabled" - : !liveProvider.installed - ? "Not installed" - : "Unavailable"; - return ( - - - ); + + } - return ( - - - - - - handleModelChange(option.value, value)} - > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - return ( - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } - {COMING_SOON_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = option.icon; - return ( - - - ); - })} - - )} - - + {triggerTitle} + + ) : ( + triggerTitle + )} + + {triggerLabel} + +