From 9403429a7af114a1b96a79c96ecda218f9e0c0b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Mar 2026 23:58:58 -0700 Subject: [PATCH 01/45] Make server settings authoritative for runtime behavior (#1421) --- .../OrchestrationEngineHarness.integration.ts | 5 + .../providerService.integration.test.ts | 3 + apps/server/src/codexAppServerManager.test.ts | 18 +- apps/server/src/codexAppServerManager.ts | 29 +- apps/server/src/config.ts | 2 + .../git/Layers/ClaudeTextGeneration.test.ts | 2 + .../src/git/Layers/ClaudeTextGeneration.ts | 11 +- .../git/Layers/CodexTextGeneration.test.ts | 271 ++--- .../src/git/Layers/CodexTextGeneration.ts | 15 +- apps/server/src/git/Layers/GitManager.test.ts | 10 +- apps/server/src/git/Layers/GitManager.ts | 15 +- apps/server/src/keybindings.ts | 34 +- apps/server/src/main.test.ts | 2 + apps/server/src/main.ts | 17 +- .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderCommandReactor.ts | 104 +- .../Layers/ProviderRuntimeIngestion.test.ts | 12 +- .../Layers/ProviderRuntimeIngestion.ts | 20 +- .../decider.projectScripts.test.ts | 1 - apps/server/src/orchestration/decider.ts | 5 - .../src/provider/Layers/ClaudeAdapter.test.ts | 14 +- .../src/provider/Layers/ClaudeAdapter.ts | 52 +- .../src/provider/Layers/ClaudeProvider.ts | 397 ++++++ .../src/provider/Layers/CodexAdapter.test.ts | 5 + .../src/provider/Layers/CodexAdapter.ts | 39 +- .../src/provider/Layers/CodexProvider.ts | 531 ++++++++ .../provider/Layers/ProviderHealth.test.ts | 640 ---------- .../src/provider/Layers/ProviderHealth.ts | 603 ---------- .../provider/Layers/ProviderRegistry.test.ts | 879 ++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 93 ++ .../provider/Layers/ProviderService.test.ts | 58 + .../src/provider/Layers/ProviderService.ts | 34 +- .../src/provider/Services/ClaudeProvider.ts | 9 + .../src/provider/Services/CodexProvider.ts | 9 + .../src/provider/Services/ProviderHealth.ts | 22 - .../src/provider/Services/ProviderRegistry.ts | 32 + .../src/provider/Services/ServerProvider.ts | 8 + .../src/provider/makeManagedServerProvider.ts | 72 ++ apps/server/src/provider/providerSnapshot.ts | 134 +++ apps/server/src/serverLayers.ts | 7 +- apps/server/src/serverSettings.test.ts | 182 +++ apps/server/src/serverSettings.ts | 339 ++++++ apps/server/src/wsServer.test.ts | 50 +- apps/server/src/wsServer.ts | 56 +- apps/server/src/wsServer/pushBus.test.ts | 3 - apps/web/src/appSettings.test.ts | 325 ----- apps/web/src/appSettings.ts | 121 -- apps/web/src/components/ChatView.browser.tsx | 11 +- apps/web/src/components/ChatView.tsx | 143 ++- apps/web/src/components/DiffPanel.tsx | 4 +- apps/web/src/components/GitActionsControl.tsx | 3 - .../components/KeybindingsToast.browser.tsx | 14 +- apps/web/src/components/PlanSidebar.tsx | 2 +- apps/web/src/components/Sidebar.logic.ts | 2 +- apps/web/src/components/Sidebar.tsx | 7 +- .../CompactComposerControlsMenu.browser.tsx | 65 + .../src/components/chat/MessagesTimeline.tsx | 2 +- .../chat/ProviderModelPicker.browser.tsx | 157 ++- .../components/chat/ProviderModelPicker.tsx | 29 +- ...lthBanner.tsx => ProviderStatusBanner.tsx} | 8 +- .../components/chat/TraitsPicker.browser.tsx | 101 +- apps/web/src/components/chat/TraitsPicker.tsx | 14 +- .../chat/composerProviderRegistry.test.tsx | 75 ++ .../chat/composerProviderRegistry.tsx | 50 +- apps/web/src/composerDraftStore.ts | 35 +- apps/web/src/hooks/useSettings.ts | 266 +++++ apps/web/src/lib/gitReactQuery.test.ts | 4 - apps/web/src/lib/gitReactQuery.ts | 4 +- apps/web/src/lib/serverReactQuery.ts | 8 +- apps/web/src/modelSelection.ts | 128 +- apps/web/src/providerModels.ts | 116 ++ apps/web/src/routes/__root.tsx | 15 +- apps/web/src/routes/_chat.settings.tsx | 1063 +++++++++++------ apps/web/src/routes/_chat.tsx | 4 +- apps/web/src/timestampFormat.ts | 21 +- apps/web/src/wsNativeApi.test.ts | 42 +- apps/web/src/wsNativeApi.ts | 35 + apps/web/src/wsTransport.test.ts | 8 +- packages/contracts/package.json | 5 + packages/contracts/src/git.test.ts | 15 - packages/contracts/src/git.ts | 2 - packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 12 +- packages/contracts/src/model.ts | 188 +-- packages/contracts/src/orchestration.ts | 26 +- packages/contracts/src/provider.test.ts | 18 - packages/contracts/src/provider.ts | 2 - packages/contracts/src/server.ts | 37 +- packages/contracts/src/settings.ts | 153 +++ packages/contracts/src/ws.test.ts | 19 + packages/contracts/src/ws.ts | 17 +- packages/shared/package.json | 4 + packages/shared/src/Struct.ts | 23 + packages/shared/src/model.test.ts | 327 +---- packages/shared/src/model.ts | 78 +- packages/shared/src/schemaJson.ts | 64 +- 96 files changed, 5378 insertions(+), 3341 deletions(-) create mode 100644 apps/server/src/provider/Layers/ClaudeProvider.ts create mode 100644 apps/server/src/provider/Layers/CodexProvider.ts delete mode 100644 apps/server/src/provider/Layers/ProviderHealth.test.ts delete mode 100644 apps/server/src/provider/Layers/ProviderHealth.ts create mode 100644 apps/server/src/provider/Layers/ProviderRegistry.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderRegistry.ts create mode 100644 apps/server/src/provider/Services/ClaudeProvider.ts create mode 100644 apps/server/src/provider/Services/CodexProvider.ts delete mode 100644 apps/server/src/provider/Services/ProviderHealth.ts create mode 100644 apps/server/src/provider/Services/ProviderRegistry.ts create mode 100644 apps/server/src/provider/Services/ServerProvider.ts create mode 100644 apps/server/src/provider/makeManagedServerProvider.ts create mode 100644 apps/server/src/provider/providerSnapshot.ts create mode 100644 apps/server/src/serverSettings.test.ts create mode 100644 apps/server/src/serverSettings.ts delete mode 100644 apps/web/src/appSettings.test.ts delete mode 100644 apps/web/src/appSettings.ts rename apps/web/src/components/chat/{ProviderHealthBanner.tsx => ProviderStatusBanner.tsx} (77%) create mode 100644 apps/web/src/hooks/useSettings.ts create mode 100644 apps/web/src/providerModels.ts create mode 100644 packages/contracts/src/settings.ts create mode 100644 packages/shared/src/Struct.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f540685b79..115d18d02b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -38,6 +38,7 @@ import { ProjectionPendingApprovalRepository } from "../src/persistence/Services import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; @@ -295,8 +296,10 @@ export const makeOrchestrationIntegrationHarness = ( providerLayer, RuntimeReceiptBusLive, ); + const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(serverSettingsLayer), ); const gitCoreLayer = Layer.succeed(GitCore, { renameBranch: (input: Parameters[0]) => @@ -309,6 +312,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitCoreLayer), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -320,6 +324,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 53b4f30a80..ef03a1ab5c 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -1,5 +1,6 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts/settings"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, assert } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; @@ -12,6 +13,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; @@ -60,6 +62,7 @@ const makeIntegrationFixture = Effect.gen(function* () { const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), + ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, ).pipe(Layer.provide(SqlitePersistenceMemory)); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 80323c7441..680d9d9608 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -342,6 +342,7 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", + binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); @@ -390,6 +391,7 @@ describe("startSession", () => { manager.startSession({ threadId: asThreadId("thread-1"), provider: "codex", + binaryPath: "codex", runtimeMode: "full-access", }), ).rejects.toThrow( @@ -948,12 +950,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" provider: "codex", cwd: workspaceDir, runtimeMode: "full-access", - providerOptions: { - codex: { - ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), - }, - }, + binaryPath: process.env.CODEX_BINARY_PATH!, + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), }); const firstTurn = await manager.sendTurn({ @@ -983,12 +981,8 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" cwd: workspaceDir, runtimeMode: "approval-required", resumeCursor: firstSession.resumeCursor, - providerOptions: { - codex: { - ...(process.env.CODEX_BINARY_PATH ? { binaryPath: process.env.CODEX_BINARY_PATH } : {}), - ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), - }, - }, + binaryPath: process.env.CODEX_BINARY_PATH!, + ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), }); expect(resumedSession.threadId).toBe(originalThreadId); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 0ac37db3e8..991a9783df 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -14,7 +14,6 @@ import { type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, - type ProviderSessionStartInput, type ProviderTurnStartResult, RuntimeMode, ProviderInteractionMode, @@ -131,7 +130,8 @@ export interface CodexAppServerStartSessionInput { readonly model?: string; readonly serviceTier?: string; readonly resumeCursor?: unknown; - readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; + readonly binaryPath: string; + readonly homePath?: string; readonly runtimeMode: RuntimeMode; } @@ -541,9 +541,8 @@ export class CodexAppServerManager extends EventEmitter normalized); } -function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { - readonly binaryPath?: string; - readonly homePath?: string; -} { - const options = input.providerOptions?.codex; - if (!options) { - return {}; - } - return { - ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), - ...(options.homePath ? { homePath: options.homePath } : {}), - }; -} - function assertSupportedCodexCliVersion(input: { readonly binaryPath: string; readonly cwd: string; @@ -1658,7 +1643,11 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; } -function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined { +function readResumeThreadId(input: { + readonly resumeCursor?: unknown; + readonly threadId?: ThreadId; + readonly runtimeMode?: RuntimeMode; +}): string | undefined { return readResumeCursorThreadId(input.resumeCursor); } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8553ce9667..29f82bccf1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -19,6 +19,7 @@ export interface ServerDerivedPaths { readonly stateDir: string; readonly dbPath: string; readonly keybindingsConfigPath: string; + readonly settingsPath: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* ( stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), + settingsPath: join(stateDir, "settings.json"), worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 0a3829798e..29ae4796fe 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -6,8 +6,10 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-claude-text-generation-test-", diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 9f48a07c51..6ffedbf7b4 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,7 +11,6 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; -import { normalizeClaudeModelOptions } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -27,6 +26,8 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ const makeClaudeTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettingsService = yield* Effect.service(ServerSettingsService); const readStreamAsString = ( operation: string, @@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), }; + const claudeSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.providers.claudeAgent, + ).pipe(Effect.catch(() => Effect.undefined)); + const runClaudeCommand = Effect.gen(function* () { const command = ChildProcess.make( - "claude", + claudeSettings?.binaryPath || "claude", [ "-p", "--output-format", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index b53d7f15bd..1b07d87d90 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const DEFAULT_TEST_MODEL_SELECTION = { provider: "codex" as const, @@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { }; const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-codex-text-generation-test-", @@ -22,7 +24,20 @@ const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( Layer.provideMerge(NodeServices.layer), ); -function makeFakeCodexBinary(dir: string) { +function makeFakeCodexBinary( + dir: string, + input: { + output: string; + exitCode?: number; + stderr?: string; + requireImage?: boolean; + requireFastServiceTier?: boolean; + requireReasoningEffort?: string; + forbidReasoningEffort?: boolean; + stdinMustContain?: string; + stdinMustNotContain?: string; + }, +) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -35,12 +50,16 @@ function makeFakeCodexBinary(dir: string) { [ "#!/bin/sh", 'output_path=""', + 'seen_image="0"', + 'seen_fast_service_tier="0"', + 'seen_reasoning_effort=""', "while [ $# -gt 0 ]; do", ' if [ "$1" = "--image" ]; then', " shift", ' if [ -n "$1" ]; then', ' seen_image="1"', " fi", + " shift", " continue", " fi", ' if [ "$1" = "--config" ]; then', @@ -53,55 +72,80 @@ function makeFakeCodexBinary(dir: string) { ' seen_reasoning_effort="$1"', " ;;", " esac", + " shift", " continue", " fi", ' if [ "$1" = "--output-last-message" ]; then', " shift", ' output_path="$1"', + " shift", + " continue", " fi", " shift", "done", 'stdin_content="$(cat)"', - 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', - ' printf "%s\\n" "missing --image input" >&2', - " exit 2", - "fi", - 'if [ "$T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER" = "1" ] && [ "$seen_fast_service_tier" != "1" ]; then', - ' printf "%s\\n" "missing fast service tier config" >&2', - " exit 5", - "fi", - 'if [ -n "$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT" ] && [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"$T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT\\"" ]; then', - ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', - " exit 6", - "fi", - 'if [ "$T3_FAKE_CODEX_FORBID_REASONING_EFFORT" = "1" ] && [ -n "$seen_reasoning_effort" ]; then', - ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', - " exit 7", - "fi", - 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', - ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "stdin missing expected content" >&2', - " exit 3", - " }", - "fi", - 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', - ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', - ' printf "%s\\n" "stdin contained forbidden content" >&2', - " exit 4", - " fi", - "fi", - 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', - ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', - "fi", + ...(input.requireImage + ? [ + 'if [ "$seen_image" != "1" ]; then', + ' printf "%s\\n" "missing --image input" >&2', + ` exit 2`, + "fi", + ] + : []), + ...(input.requireFastServiceTier + ? [ + 'if [ "$seen_fast_service_tier" != "1" ]; then', + ' printf "%s\\n" "missing fast service tier config" >&2', + ` exit 5`, + "fi", + ] + : []), + ...(input.requireReasoningEffort !== undefined + ? [ + `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, + ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', + ` exit 6`, + "fi", + ] + : []), + ...(input.forbidReasoningEffort + ? [ + 'if [ -n "$seen_reasoning_effort" ]; then', + ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', + ` exit 7`, + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + ` exit 3`, + "fi", + ] + : []), + ...(input.stdinMustNotContain !== undefined + ? [ + `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin contained forbidden content" >&2', + ` exit 4`, + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), 'if [ -n "$output_path" ]; then', - ' node -e \'const fs=require("node:fs"); const value=process.argv[2] ?? ""; fs.writeFileSync(process.argv[1], Buffer.from(value, "base64"));\' "$output_path" "${T3_FAKE_CODEX_OUTPUT_B64:-e30=}"', + " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", + input.output, + "__T3CODE_FAKE_CODEX_OUTPUT__", "fi", - 'exit "${T3_FAKE_CODEX_EXIT_CODE:-0}"', + `exit ${input.exitCode ?? 0}`, "", ].join("\n"), ); yield* fs.chmod(codexPath, 0o755); - return binDir; + return codexPath; }); } @@ -123,146 +167,29 @@ function withFakeCodexEnv( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); - const binDir = yield* makeFakeCodexBinary(tempDir); - const previousPath = process.env.PATH; - const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; - const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CODEX_STDERR; - const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; - const previousRequireFastServiceTier = process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; - const previousRequireReasoningEffort = process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; - const previousForbidReasoningEffort = process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; - const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; - const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; - - yield* Effect.sync(() => { - process.env.PATH = `${binDir}:${previousPath ?? ""}`; - process.env.T3_FAKE_CODEX_OUTPUT_B64 = Buffer.from(input.output, "utf8").toString("base64"); - - if (input.exitCode !== undefined) { - process.env.T3_FAKE_CODEX_EXIT_CODE = String(input.exitCode); - } else { - delete process.env.T3_FAKE_CODEX_EXIT_CODE; - } - - if (input.stderr !== undefined) { - process.env.T3_FAKE_CODEX_STDERR = input.stderr; - } else { - delete process.env.T3_FAKE_CODEX_STDERR; - } - - if (input.requireImage) { - process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1"; - } else { - delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; - } - - if (input.requireFastServiceTier) { - process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = "1"; - } else { - delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; - } - - if (input.requireReasoningEffort !== undefined) { - process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = input.requireReasoningEffort; - } else { - delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; - } - - if (input.forbidReasoningEffort) { - process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = "1"; - } else { - delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; - } - - if (input.stdinMustContain !== undefined) { - process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; - } else { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; - } - - if (input.stdinMustNotContain !== undefined) { - process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain; - } else { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; - } + const codexPath = yield* makeFakeCodexBinary(tempDir, input); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: codexPath, + }, + }, }); - - return { - previousPath, - previousOutput, - previousExitCode, - previousStderr, - previousRequireImage, - previousRequireFastServiceTier, - previousRequireReasoningEffort, - previousForbidReasoningEffort, - previousStdinMustContain, - previousStdinMustNotContain, - }; + return { serverSettings, previousBinaryPath: previousSettings.providers.codex.binaryPath }; }), () => effect, - (previous) => - Effect.sync(() => { - process.env.PATH = previous.previousPath; - - if (previous.previousOutput === undefined) { - delete process.env.T3_FAKE_CODEX_OUTPUT_B64; - } else { - process.env.T3_FAKE_CODEX_OUTPUT_B64 = previous.previousOutput; - } - - if (previous.previousExitCode === undefined) { - delete process.env.T3_FAKE_CODEX_EXIT_CODE; - } else { - process.env.T3_FAKE_CODEX_EXIT_CODE = previous.previousExitCode; - } - - if (previous.previousStderr === undefined) { - delete process.env.T3_FAKE_CODEX_STDERR; - } else { - process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; - } - - if (previous.previousRequireImage === undefined) { - delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; - } else { - process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; - } - - if (previous.previousRequireFastServiceTier === undefined) { - delete process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER; - } else { - process.env.T3_FAKE_CODEX_REQUIRE_FAST_SERVICE_TIER = - previous.previousRequireFastServiceTier; - } - - if (previous.previousRequireReasoningEffort === undefined) { - delete process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT; - } else { - process.env.T3_FAKE_CODEX_REQUIRE_REASONING_EFFORT = - previous.previousRequireReasoningEffort; - } - - if (previous.previousForbidReasoningEffort === undefined) { - delete process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT; - } else { - process.env.T3_FAKE_CODEX_FORBID_REASONING_EFFORT = - previous.previousForbidReasoningEffort; - } - - if (previous.previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; - } - - if (previous.previousStdinMustNotContain === undefined) { - delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain; - } - }), + ({ serverSettings, previousBinaryPath }) => + serverSettings + .updateSettings({ + providers: { + codex: { + binaryPath: previousBinaryPath, + }, + }, + }) + .pipe(Effect.asVoid), ); } diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index afe972ab4a..8f332bf13e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -4,7 +4,6 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from " import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@t3tools/contracts"; -import { normalizeCodexModelOptions } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -26,6 +25,8 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const serverSettingsService = yield* Effect.service(ServerSettingsService); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); + const codexSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.providers.codex, + ).pipe(Effect.catch(() => Effect.undefined)); + const runCodexCommand = Effect.gen(function* () { const normalizedOptions = normalizeCodexModelOptions( modelSelection.model, @@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const reasoningEffort = modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( - "codex", + codexSettings?.binaryPath || "codex", [ "exec", "--ephemeral", @@ -165,6 +172,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-", ], { + env: { + ...process.env, + ...(codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + }, cwd, shell: process.platform === "win32", stdin: { diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 9302c562e8..57d6853c4a 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -20,11 +20,7 @@ import { GitCoreLive } from "./GitCore.ts"; import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; - -const DEFAULT_TEST_MODEL_SELECTION = { - provider: "codex", - model: "gpt-5.4-mini", -} as const; +import { ServerSettingsService } from "../../serverSettings.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -466,7 +462,6 @@ function runStackedAction( { ...input, actionId: input.actionId ?? "test-action-id", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, }, options, ); @@ -493,6 +488,8 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); + const serverSettingsLayer = ServerSettingsService.layerTest(); + const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), @@ -502,6 +499,7 @@ function makeManager(input?: { Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, + serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); return makeGitManager.pipe( diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index e2ae56a90c..6fd86e1d58 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -19,6 +19,7 @@ import { import { GitCore } from "../Services/GitCore.ts"; import { GitHubCli } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -358,6 +359,7 @@ export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, @@ -1173,6 +1175,13 @@ export const makeGitManager = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; + const modelSelection = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.textGenerationModelSelection), + Effect.mapError((cause) => + gitManagerError("runStackedAction", "Failed to get server settings.", cause), + ), + ); + if (input.featureBranch) { currentPhase = "branch"; yield* progress.emit({ @@ -1181,7 +1190,7 @@ export const makeGitManager = Effect.gen(function* () { label: "Preparing feature branch...", }); const result = yield* runFeatureBranchStep( - input.modelSelection, + modelSelection, input.cwd, initialStatus.branch, input.commitMessage, @@ -1198,7 +1207,7 @@ export const makeGitManager = Effect.gen(function* () { currentPhase = "commit"; const commit = yield* runCommitStep( - input.modelSelection, + modelSelection, input.cwd, input.action, currentBranch, @@ -1237,7 +1246,7 @@ export const makeGitManager = Effect.gen(function* () { Effect.flatMap(() => Effect.gen(function* () { currentPhase = "pr"; - return yield* runPrStep(input.modelSelection, input.cwd, currentBranch); + return yield* runPrStep(modelSelection, input.cwd, currentBranch); }), ), ) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf58467825..58363d2138 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -23,6 +23,7 @@ import { Cache, Cause, Deferred, + Duration, Effect, Exit, FileSystem, @@ -42,6 +43,7 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; export class KeybindingsConfigError extends Schema.TaggedErrorClass()( "KeybindingsConfigParseError", @@ -408,7 +410,7 @@ function encodeWhenAst(node: KeybindingWhenNode): string { const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); -const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); +const RawKeybindingsEntries = fromLenientJson(Schema.Array(Schema.Unknown)); const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); const PrettyJsonString = SchemaGetter.parseJson().compose( SchemaGetter.stringifyJson({ space: 2 }), @@ -672,6 +674,7 @@ const makeKeybindings = Effect.gen(function* () { Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), Effect.mapError( (cause) => new KeybindingsConfigError({ @@ -817,16 +820,25 @@ const makeKeybindings = Effect.gen(function* () { const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); - yield* Stream.runForEach(fs.watch(keybindingsConfigDir), (event) => { - const isTargetConfigEvent = - event.path === keybindingsConfigFile || - event.path === keybindingsConfigPath || - path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved; - if (!isTargetConfigEvent) { - return Effect.void; - } - return revalidateAndEmitSafely; - }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); + // Debounce watch events so the file is fully written before we read it. + // Editors emit multiple events per save (truncate, write, rename) and + // `fs.watch` can fire before the content has been flushed to disk. + const debouncedKeybindingsEvents = fs.watch(keybindingsConfigDir).pipe( + Stream.filter((event) => { + return ( + event.path === keybindingsConfigFile || + event.path === keybindingsConfigPath || + path.resolve(keybindingsConfigDir, event.path) === keybindingsConfigPathResolved + ); + }), + Stream.debounce(Duration.millis(100)), + ); + + yield* Stream.runForEach(debouncedKeybindingsEvents, () => revalidateAndEmitSafely).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + Effect.asVoid, + ); }); const start = Effect.gen(function* () { diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 369a66c088..c644b4778e 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -17,6 +17,7 @@ import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./wsServer"; +import { ServerSettingsService } from "./serverSettings"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); @@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll( openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), + ServerSettingsService.layerTest(), AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5b21252884..019783c253 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -22,12 +22,13 @@ import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { readBootstrapEnvelope } from "./bootstrap"; +import { ServerSettingsLive } from "./serverSettings"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -293,10 +294,11 @@ const LayerLive = (input: CliInput) => Layer.empty.pipe( Layer.provideMerge(makeServerRuntimeServicesLayer()), Layer.provideMerge(makeServerProviderLayer()), - Layer.provideMerge(ProviderHealthLive), + Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(ServerConfigLive(input)), ); @@ -331,12 +333,10 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -const makeServerProgram = (input: CliInput) => +const makeServerRuntimeProgram = (input: CliInput) => Effect.gen(function* () { - const cliConfig = yield* CliConfig; const { start, stopSignal } = yield* Server; const openDeps = yield* Open; - yield* cliConfig.fixPath; const config = yield* ServerConfig; @@ -378,6 +378,13 @@ const makeServerProgram = (input: CliInput) => return yield* stopSignal; }).pipe(Effect.provide(LayerLive(input))); +const makeServerProgram = (input: CliInput) => + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + yield* cliConfig.fixPath; + return yield* makeServerRuntimeProgram(input); + }); + /** * These flags mirrors the environment variables and the config shape. */ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b58c2522cb..834ab9be9e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -34,6 +34,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerSettingsService } from "../../serverSettings.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => @@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 737ad665d7..7c522e5799 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,12 +1,10 @@ import { type ChatAttachment, CommandId, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, EventId, type ModelSelection, type OrchestrationEvent, ProviderKind, - type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -26,6 +24,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -138,6 +137,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const git = yield* GitCore; const textGeneration = yield* TextGeneration; + const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -151,7 +151,6 @@ const make = Effect.gen(function* () { ), ); - const threadProviderOptions = new Map(); const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { @@ -210,7 +209,6 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -258,9 +256,6 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, - ...(options?.providerOptions !== undefined - ? { providerOptions: options.providerOptions } - : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -354,7 +349,6 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -362,13 +356,11 @@ const make = Effect.gen(function* () { if (!thread) { return; } - yield* ensureSessionForThread(input.threadId, input.createdAt, { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), - }); - if (input.providerOptions !== undefined) { - threadProviderOptions.set(input.threadId, input.providerOptions); - } + yield* ensureSessionForThread( + input.threadId, + input.createdAt, + input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, + ); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } @@ -432,48 +424,39 @@ const make = Effect.gen(function* () { const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; - yield* textGeneration - .generateBranchName({ + yield* Effect.gen(function* () { + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + + const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection: { - provider: "codex", - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - }, - }) - .pipe( - Effect.catch((error) => - Effect.logWarning( - "provider command reactor failed to generate worktree branch name; skipping rename", - { threadId: input.threadId, cwd, oldBranch, reason: error.message }, - ), - ), - Effect.flatMap((generated) => { - if (!generated) return Effect.void; - - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); - if (targetBranch === oldBranch) return Effect.void; - - return Effect.flatMap( - git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), - (renamed) => - orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("worktree-branch-rename"), - threadId: input.threadId, - branch: renamed.branch, - worktreePath: cwd, - }), - ); + modelSelection, + }); + if (!generated) return; + + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + if (targetBranch === oldBranch) return; + + const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("worktree-branch-rename"), + threadId: input.threadId, + branch: renamed.branch, + worktreePath: cwd, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { + threadId: input.threadId, + cwd, + oldBranch, + cause: Cause.pretty(cause), }), - Effect.catchCause((cause) => - Effect.logWarning( - "provider command reactor failed to generate or rename worktree branch", - { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, - ), - ), - ); + ), + ); }); const processTurnStartRequested = Effect.fnUntraced(function* ( @@ -518,9 +501,6 @@ const make = Effect.gen(function* () { ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...(event.payload.providerOptions !== undefined - ? { providerOptions: event.payload.providerOptions } - : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( @@ -686,14 +666,12 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); const cachedModelSelection = threadModelSelections.get(event.payload.threadId); - yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { - ...(cachedProviderOptions !== undefined - ? { providerOptions: cachedProviderOptions } - : {}), - ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), - }); + yield* ensureSessionForThread( + event.payload.threadId, + event.occurredAt, + cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, + ); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b29df5c8fe..8d205bbe2f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -16,6 +16,7 @@ import { MessageId, ProjectId, ProviderItemId, + type ServerSettings, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -38,8 +39,13 @@ import { } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +function makeTestServerSettingsLayer(overrides: Partial = {}) { + return ServerSettingsService.layerTest(overrides); +} + const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); @@ -155,7 +161,7 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness() { + async function createHarness(options?: { serverSettings?: Partial }) { const workspaceRoot = makeTempDir("t3-provider-project-"); fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -169,6 +175,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); @@ -1357,7 +1364,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const now = new Date().toISOString(); await Effect.runPromise( @@ -1371,7 +1378,6 @@ describe("ProviderRuntimeIngestion", () => { text: "stream please", attachments: [], }, - assistantDeliveryMode: "streaming", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0b7af8bd4b..f9a662b84f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -13,7 +13,7 @@ import { type OrchestrationThreadActivity, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; +import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -26,12 +26,12 @@ import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); -const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; @@ -537,10 +537,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; - - const assistantDeliveryModeRef = yield* Ref.make( - DEFAULT_ASSISTANT_DELIVERY_MODE, - ); + const serverSettingsService = yield* ServerSettingsService; const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); if (spillChunk.length > 0) { @@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () { ).pipe(Effect.asVoid); }); - const processDomainEvent = (event: TurnStartRequestedDomainEvent) => - Ref.set( - assistantDeliveryModeRef, - event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, - ); + const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; const processInput = (input: RuntimeIngestionInput) => input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 69a9117824..465865549b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -188,7 +188,6 @@ describe("decider project scripts", () => { if (turnStartEvent?.type !== "thread.turn-start-requested") { return; } - expect(turnStartEvent.payload.assistantDeliveryMode).toBe("buffered"); expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), messageId: asMessageId("message-user-1"), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 761ab56a7d..c4cfd2314b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -14,7 +14,6 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); -const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], @@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.providerOptions !== undefined - ? { providerOptions: command.providerOptions } - : {}), - assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d4ed6fba19..4e8238dbe6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -21,6 +21,7 @@ import { Effect, Fiber, Layer, Random, Stream } from "effect"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapterLive, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; @@ -168,6 +169,7 @@ function makeHarness(config?: { config?.baseDir ?? "/tmp", ), ), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ), query, @@ -301,7 +303,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { + it.effect("uses bypass permissions for full-access claude sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -309,16 +311,11 @@ describe("ClaudeAdapterLive", () => { threadId: THREAD_ID, provider: "claudeAgent", runtimeMode: "full-access", - providerOptions: { - claudeAgent: { - permissionMode: "plan", - }, - }, }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.permissionMode, "plan"); - assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -1195,6 +1192,7 @@ describe("ClaudeAdapterLive", () => { }, }).pipe( Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index af88fa634a..7ab8bc44ab 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,12 +40,7 @@ import { type UserInputQuestion, ClaudeCodeEffort, } from "@t3tools/contracts"; -import { - hasEffortLevel, - applyClaudePromptEffortPrefix, - getModelCapabilities, - trimOrNull, -} from "@t3tools/shared/model"; +import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model"; import { Cause, DateTime, @@ -63,6 +58,8 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -353,19 +350,6 @@ function asRuntimeRequestId(value: ApprovalRequestId): RuntimeRequestId { return RuntimeRequestId.makeUnsafe(value); } -function toPermissionMode(value: unknown): PermissionMode | undefined { - switch (value) { - case "default": - case "acceptEdits": - case "bypassPermissions": - case "plan": - case "dontAsk": - return value; - default: - return undefined; - } -} - function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undefined { if (!resumeCursor || typeof resumeCursor !== "object") { return undefined; @@ -525,7 +509,7 @@ function buildPromptText(input: ProviderSendTurnInput): string { const requestedEffort = trimOrNull(rawEffort); const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; - const caps = getModelCapabilities("claudeAgent", claudeModel); + const caps = getClaudeModelCapabilities(claudeModel); const promptEffort = requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 ? "ultrathink" @@ -943,6 +927,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); + const serverSettingsService = yield* ServerSettingsService; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); @@ -2727,11 +2712,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }), ); - const providerOptions = input.providerOptions?.claudeAgent; + const claudeSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.claudeAgent), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); - const caps = getModelCapabilities("claudeAgent", modelSelection?.model); + const caps = getClaudeModelCapabilities(modelSelection?.model); const effort = requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; @@ -2741,8 +2738,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { : undefined; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = - toPermissionMode(providerOptions?.permissionMode) ?? - (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); + input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -2751,16 +2747,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(modelSelection?.model ? { model: modelSelection.model } : {}), - pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", + pathToClaudeCodeExecutable: claudeBinaryPath, settingSources: [...CLAUDE_SETTING_SOURCES], ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } - : {}), ...(Object.keys(settings).length > 0 ? { settings } : {}), ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), @@ -2851,9 +2844,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } - : {}), ...(fastMode ? { fastMode: true } : {}), }, }, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts new file mode 100644 index 0000000000..e51f5096db --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -0,0 +1,397 @@ +import type { + ClaudeSettings, + ClaudeModelOptions, + ModelCapabilities, + ServerProvider, + ServerProviderModel, + ServerProviderAuthStatus, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + DEFAULT_TIMEOUT_MS, + detailFromResult, + extractAuthBoolean, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { ClaudeProvider } from "../Services/ClaudeProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "claudeAgent" as const; +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + } satisfies ModelCapabilities, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + } satisfies ModelCapabilities, + }, +]; + +export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { + const slug = model?.trim(); + return ( + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + } + ); +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const caps = getClaudeModelCapabilities(model); + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function parseClaudeAuthStatusFromOutput(result: CommandResult): { + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: + "Claude Agent authentication status command is unavailable in this version of Claude.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `claude login`") || + lowerOutput.includes("run claude login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude is not authenticated. Run `claude auth login` and try again.", + }; + } + + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Claude is not authenticated. Run `claude auth login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Claude authentication status. ${detail}` + : "Could not verify Claude authentication status.", + }; +} + +const runClaudeCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.claudeAgent), + ); + const command = ChildProcess.make(claudeSettings.binaryPath, [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + > { + const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.claudeAgent), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + ); + + if (!claudeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "Claude is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + authStatus: "unknown", + message: isCommandMissingCause(error) + ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: + "Claude Agent CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: detail + ? `Claude Agent CLI is installed but failed to run. ${detail}` + : "Claude Agent CLI is installed but failed to run.", + }, + }); + } + + const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + error instanceof Error + ? `Could not verify Claude authentication status: ${error.message}.` + : "Could not verify Claude authentication status.", + }, + }); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + "Could not verify Claude authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); + }, +); + +export const ClaudeProviderLive = Layer.effect( + ClaudeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkClaudeProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.claudeAgent), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.claudeAgent), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3017235f1e..97ed621c7b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -22,6 +22,7 @@ import { type CodexAppServerSendTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -151,6 +152,7 @@ const validationManager = new FakeCodexManager(); const validationLayer = it.layer( makeCodexAdapterLive({ manager: validationManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -201,6 +203,7 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { provider: "codex", threadId: asThreadId("thread-1"), + binaryPath: "codex", model: "gpt-5.3-codex", serviceTier: "fast", runtimeMode: "full-access", @@ -216,6 +219,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { const sessionErrorLayer = it.layer( makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -284,6 +288,7 @@ const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index ca9c52cf8e..9af5aac19d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -38,6 +38,7 @@ import { } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; @@ -1342,25 +1343,39 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => } }), ); + const serverSettingsService = yield* ServerSettingsService; - const startSession: CodexAdapterShape["startSession"] = (input) => { + const startSession: CodexAdapterShape["startSession"] = Effect.fn(function* (input) { if (input.provider !== undefined && input.provider !== PROVIDER) { - return Effect.fail( - new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }), - ); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); } + const codexSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.codex), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const binaryPath = codexSettings.binaryPath; + const homePath = codexSettings.homePath; const managerInput: CodexAppServerStartSessionInput = { threadId: input.threadId, provider: "codex", ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), runtimeMode: input.runtimeMode, + binaryPath, + ...(homePath ? { homePath } : {}), ...(input.modelSelection?.provider === "codex" ? { model: input.modelSelection.model } : {}), @@ -1369,7 +1384,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => : {}), }; - return Effect.tryPromise({ + return yield* Effect.tryPromise({ try: () => manager.startSession(managerInput), catch: (cause) => new ProviderAdapterProcessError({ @@ -1378,8 +1393,8 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => detail: toMessage(cause, "Failed to start Codex adapter session."), cause, }), - }).pipe(Effect.map((session) => session)); - }; + }); + }); const sendTurn: CodexAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts new file mode 100644 index 0000000000..913fbb58d5 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -0,0 +1,531 @@ +import * as OS from "node:os"; +import type { + ModelCapabilities, + CodexModelOptions, + CodexSettings, + ServerProvider, + ServerProviderModel, + ServerProviderAuthStatus, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + DEFAULT_TIMEOUT_MS, + detailFromResult, + extractAuthBoolean, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { + formatCodexCliUpgradeMessage, + isCodexCliVersionSupported, + parseCodexCliVersion, +} from "../codexCliVersion"; +import { CodexProvider } from "../Services/CodexProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "codex" as const; +const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 Mini", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.2", + name: "GPT-5.2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + +export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { + const slug = model?.trim(); + return ( + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + } + ); +} + +export function normalizeCodexModelOptions( + model: string | null | undefined, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const caps = getCodexModelCapabilities(model); + const defaultReasoningEffort = getDefaultEffort(caps); + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function parseAuthStatusFromOutput(result: CommandResult): { + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} { + const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); + + if ( + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") + ) { + return { + status: "warning", + authStatus: "unknown", + message: "Codex CLI authentication status command is unavailable in this Codex version.", + }; + } + + if ( + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `codex login`") || + lowerOutput.includes("run codex login") + ) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + try { + return { + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), + }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; + } + })(); + + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; + } + if (parsedAuth.auth === false) { + return { + status: "error", + authStatus: "unauthenticated", + message: "Codex CLI is not authenticated. Run `codex login` and try again.", + }; + } + if (parsedAuth.attemptedJsonParse) { + return { + status: "warning", + authStatus: "unknown", + message: + "Could not verify Codex authentication status from JSON output (missing auth marker).", + }; + } + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; + } + + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Codex authentication status. ${detail}` + : "Could not verify Codex authentication status.", + }; +} + +export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvider")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const settingsService = yield* ServerSettingsService; + const codexHome = yield* settingsService.getSettings.pipe( + Effect.map( + (settings) => + settings.providers.codex.homePath || + process.env.CODEX_HOME || + path.join(OS.homedir(), ".codex"), + ), + ); + const configPath = path.join(codexHome, "config.toml"); + + const content = yield* fileSystem + .readFileString(configPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (content === undefined) { + return undefined; + } + + let inTopLevel = true; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("[")) { + inTopLevel = false; + continue; + } + if (!inTopLevel) continue; + + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (match) return match[1]; + } + return undefined; +}); + +export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( + Effect.map((provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider)), + Effect.orElseSucceed(() => false), +); + +const runCodexCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const settingsService = yield* ServerSettingsService; + const codexSettings = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.codex), + ); + const command = ChildProcess.make(codexSettings.binaryPath, [...args], { + shell: process.platform === "win32", + env: { + ...process.env, + ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + }, + }); + + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ServerSettingsService + > { + const codexSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.codex), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + ); + + if (!codexSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "Codex is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runCodexCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + authStatus: "unknown", + message: isCommandMissingCause(error) + ? "Codex CLI (`codex`) is not installed or not on PATH." + : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: "Codex CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = + parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ?? + parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: detail + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", + }, + }); + } + + if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: formatCodexCliUpgradeMessage(parsedVersion), + }, + }); + } + + if (yield* hasCustomModelProvider) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "ready", + authStatus: "unknown", + message: "Using a custom Codex model provider; OpenAI login check skipped.", + }, + }); + } + + const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + error instanceof Error + ? `Could not verify Codex authentication status: ${error.message}.` + : "Could not verify Codex authentication status.", + }, + }); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: "Could not verify Codex authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); + }, +); + +export const CodexProviderLive = Layer.effect( + CodexProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const checkProvider = checkCodexProviderStatus().pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.codex), + Effect.orDie, + ), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.codex), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts deleted file mode 100644 index e24f07bcfa..0000000000 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ /dev/null @@ -1,640 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, it, assert } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect"; -import * as PlatformError from "effect/PlatformError"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { - checkClaudeProviderStatus, - checkCodexProviderStatus, - hasCustomModelProvider, - parseAuthStatusFromOutput, - parseClaudeAuthStatusFromOutput, - readCodexConfigModelProvider, -} from "./ProviderHealth"; - -// ── Test helpers ──────────────────────────────────────────────────── - -const encoder = new TextEncoder(); - -function mockHandle(result: { stdout: string; stderr: string; code: number }) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: Sink.drain, - stdout: Stream.make(encoder.encode(result.stdout)), - stderr: Stream.make(encoder.encode(result.stderr)), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - -function mockSpawnerLayer( - handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, -) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const cmd = command as unknown as { args: ReadonlyArray }; - return Effect.succeed(mockHandle(handler(cmd.args))); - }), - ); -} - -function failingSpawnerLayer(description: string) { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description, - }), - ), - ), - ); -} - -/** - * Create a temporary CODEX_HOME scoped to the current Effect test. - * Cleanup is registered in the test scope rather than via Vitest hooks. - */ -function withTempCodexHome(configContent?: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); - - yield* Effect.acquireRelease( - Effect.sync(() => { - const originalCodexHome = process.env.CODEX_HOME; - process.env.CODEX_HOME = tmpDir; - return originalCodexHome; - }), - (originalCodexHome) => - Effect.sync(() => { - if (originalCodexHome !== undefined) { - process.env.CODEX_HOME = originalCodexHome; - } else { - delete process.env.CODEX_HOME; - } - }), - ); - - if (configContent !== undefined) { - yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); - } - - return { tmpDir } as const; - }); -} - -it.layer(NodeServices.layer)("ProviderHealth", (it) => { - // ── checkCodexProviderStatus tests ──────────────────────────────── - // - // These tests control CODEX_HOME to ensure the custom-provider detection - // in hasCustomModelProvider() does not interfere with the auth-probe - // path being tested. - - describe("checkCodexProviderStatus", () => { - it.effect("returns ready when codex is installed and authenticated", () => - Effect.gen(function* () { - // Point CODEX_HOME at an empty tmp dir (no config.toml) so the - // default code path (OpenAI provider, auth probe runs) is exercised. - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), - ); - - it.effect("returns unavailable when codex is below the minimum supported version", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when auth probe reports login required", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when login status output includes 'not logged in'", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when login status command is unsupported", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI authentication status command is unavailable in this Codex version.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); - - // ── Custom model provider: checkCodexProviderStatus integration ─── - - describe("checkCodexProviderStatus with custom model provider", () => { - it.effect("skips auth probe and returns ready when a custom model provider is configured", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), - ); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Using a custom Codex model provider; OpenAI login check skipped.", - ); - }).pipe( - Effect.provide( - // The spawner only handles --version; if the test attempts - // "login status" the throw proves the auth probe was NOT skipped. - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - throw new Error(`Auth probe should have been skipped but got args: ${joined}`); - }), - ), - ), - ); - - it.effect("still reports error when codex CLI is missing even with custom provider", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model_provider = "portkey"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'env_key = "PORTKEY_API_KEY"', - ].join("\n"), - ); - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), - ); - }); - - describe("checkCodexProviderStatus with openai model provider", () => { - it.effect("still runs auth probe when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - const status = yield* checkCodexProviderStatus; - // The auth probe runs and sees "not logged in" → error - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.authStatus, "unauthenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") - return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); - - // ── parseAuthStatusFromOutput pure tests ────────────────────────── - - describe("parseAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); - }); - - it("JSON with authenticated=false is unauthenticated", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"authenticated":false}]\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"ok":true}]\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); - }); - }); - - // ── readCodexConfigModelProvider tests ───────────────────────────── - - describe("readCodexConfigModelProvider", () => { - it.effect("returns undefined when config file does not exist", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("returns undefined when config has no model_provider key", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("returns the provider when model_provider is set at top level", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "portkey"); - }), - ); - - it.effect("returns openai when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* readCodexConfigModelProvider, "openai"); - }), - ); - - it.effect("ignores model_provider inside section headers", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - 'model = "gpt-5-codex"', - "", - "[model_providers.portkey]", - 'base_url = "https://api.portkey.ai/v1"', - 'model_provider = "should-be-ignored"', - "", - ].join("\n"), - ); - assert.strictEqual(yield* readCodexConfigModelProvider, undefined); - }), - ); - - it.effect("handles comments and whitespace", () => - Effect.gen(function* () { - yield* withTempCodexHome( - [ - "# This is a comment", - "", - ' model_provider = "azure" ', - "", - "[profiles.deep-review]", - 'model = "gpt-5-pro"', - ].join("\n"), - ); - assert.strictEqual(yield* readCodexConfigModelProvider, "azure"); - }), - ); - - it.effect("handles single-quoted values in TOML", () => - Effect.gen(function* () { - yield* withTempCodexHome("model_provider = 'mistral'\n"); - assert.strictEqual(yield* readCodexConfigModelProvider, "mistral"); - }), - ); - }); - - // ── hasCustomModelProvider tests ─────────────────────────────────── - - describe("hasCustomModelProvider", () => { - it.effect("returns false when no config file exists", () => - Effect.gen(function* () { - yield* withTempCodexHome(); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is not set", () => - Effect.gen(function* () { - yield* withTempCodexHome('model = "gpt-5-codex"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns false when model_provider is openai", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "openai"\n'); - assert.strictEqual(yield* hasCustomModelProvider, false); - }), - ); - - it.effect("returns true when model_provider is portkey", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "portkey"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is azure", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "azure"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is ollama", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "ollama"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - - it.effect("returns true when model_provider is a custom proxy", () => - Effect.gen(function* () { - yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); - assert.strictEqual(yield* hasCustomModelProvider, true); - }), - ); - }); - - // ── checkClaudeProviderStatus tests ────────────────────────── - - describe("checkClaudeProviderStatus", () => { - it.effect("returns ready when claude is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unavailable when claude is missing", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", - ); - }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), - ); - - it.effect("returns error when version check fails with non-zero exit code", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") - return { stdout: "", stderr: "Something went wrong", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when auth status reports not logged in", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Claude is not authenticated. Run `claude auth login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns unauthenticated when output includes 'not logged in'", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") return { stdout: "Not logged in\n", stderr: "", code: 1 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - - it.effect("returns warning when auth status command is unsupported", () => - Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus; - assert.strictEqual(status.provider, "claudeAgent"); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent authentication status command is unavailable in this version of Claude.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), - ); - }); - - // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── - - describe("parseClaudeAuthStatusFromOutput", () => { - it("exit code 0 with no auth markers is ready", () => { - const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); - }); - - it("JSON with loggedIn=true is authenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); - }); - - it("JSON with loggedIn=false is unauthenticated", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); - }); - - it("JSON without auth marker is warning", () => { - const parsed = parseClaudeAuthStatusFromOutput({ - stdout: '{"ok":true}\n', - stderr: "", - code: 0, - }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); - }); - }); -}); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts deleted file mode 100644 index cbb97a807e..0000000000 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ /dev/null @@ -1,603 +0,0 @@ -/** - * ProviderHealthLive - Startup-time provider health checks. - * - * Performs one-time provider readiness probes when the server starts and - * keeps the resulting snapshot in memory for `server.getConfig`. - * - * Uses effect's ChildProcessSpawner to run CLI probes natively. - * - * @module ProviderHealthLive - */ -import * as OS from "node:os"; -import type { - ServerProviderAuthStatus, - ServerProviderStatus, - ServerProviderStatusState, -} from "@t3tools/contracts"; -import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { - formatCodexCliUpgradeMessage, - isCodexCliVersionSupported, - parseCodexCliVersion, -} from "../codexCliVersion"; -import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; - -const DEFAULT_TIMEOUT_MS = 4_000; -const CODEX_PROVIDER = "codex" as const; -const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; - -// ── Pure helpers ──────────────────────────────────────────────────── - -export interface CommandResult { - readonly stdout: string; - readonly stderr: string; - readonly code: number; -} - -function nonEmptyTrimmed(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function isCommandMissingCause(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const lower = error.message.toLowerCase(); - return lower.includes("enoent") || lower.includes("notfound"); -} - -function detailFromResult( - result: CommandResult & { readonly timedOut?: boolean }, -): string | undefined { - if (result.timedOut) return "Timed out while running command."; - const stderr = nonEmptyTrimmed(result.stderr); - if (stderr) return stderr; - const stdout = nonEmptyTrimmed(result.stdout); - if (stdout) return stdout; - if (result.code !== 0) { - return `Command exited with code ${result.code}.`; - } - return undefined; -} - -function extractAuthBoolean(value: unknown): boolean | undefined { - if (Array.isArray(value)) { - for (const entry of value) { - const nested = extractAuthBoolean(entry); - if (nested !== undefined) return nested; - } - return undefined; - } - - if (!value || typeof value !== "object") return undefined; - - const record = value as Record; - for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { - if (typeof record[key] === "boolean") return record[key]; - } - for (const key of ["auth", "status", "session", "account"] as const) { - const nested = extractAuthBoolean(record[key]); - if (nested !== undefined) return nested; - } - return undefined; -} - -export function parseAuthStatusFromOutput(result: CommandResult): { - readonly status: ServerProviderStatusState; - readonly authStatus: ServerProviderAuthStatus; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - authStatus: "unknown", - message: "Codex CLI authentication status command is unavailable in this Codex version.", - }; - } - - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `codex login`") || - lowerOutput.includes("run codex login") - ) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; - } - - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Codex CLI is not authenticated. Run `codex login` and try again.", - }; - } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - authStatus: "unknown", - message: - "Could not verify Codex authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; - } - - const detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Codex authentication status. ${detail}` - : "Could not verify Codex authentication status.", - }; -} - -// ── Codex CLI config detection ────────────────────────────────────── - -/** - * Providers that use OpenAI-native authentication via `codex login`. - * When the configured `model_provider` is one of these, the `codex login - * status` probe still runs. For any other provider value the auth probe - * is skipped because authentication is handled externally (e.g. via - * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). - */ -const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); - -/** - * Read the `model_provider` value from the Codex CLI config file. - * - * Looks for the file at `$CODEX_HOME/config.toml` (falls back to - * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than - * a full TOML parser to avoid adding a dependency for a single key. - * - * Returns `undefined` when the file does not exist or does not set - * `model_provider`. - */ -export const readCodexConfigModelProvider = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"); - const configPath = path.join(codexHome, "config.toml"); - - const content = yield* fileSystem - .readFileString(configPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (content === undefined) { - return undefined; - } - - // We need to find `model_provider = "..."` at the top level of the - // TOML file (i.e. before any `[section]` header). Lines inside - // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. - let inTopLevel = true; - for (const line of content.split("\n")) { - const trimmed = line.trim(); - // Skip comments and empty lines. - if (!trimmed || trimmed.startsWith("#")) continue; - // Detect section headers — once we leave the top level, stop. - if (trimmed.startsWith("[")) { - inTopLevel = false; - continue; - } - if (!inTopLevel) continue; - - const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); - if (match) return match[1]; - } - return undefined; -}); - -/** - * Returns `true` when the Codex CLI is configured with a custom - * (non-OpenAI) model provider, meaning `codex login` auth is not - * required because authentication is handled through provider-specific - * environment variables. - */ -export const hasCustomModelProvider = Effect.map( - readCodexConfigModelProvider, - (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider), -); - -// ── Effect-native command execution ───────────────────────────────── - -const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => - Stream.runFold( - stream, - () => "", - (acc, chunk) => acc + new TextDecoder().decode(chunk), - ); - -const runCodexCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("codex", [...args], { - shell: process.platform === "win32", - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -const runClaudeCommand = (args: ReadonlyArray) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make("claude", [...args], { - shell: process.platform === "win32", - }); - - const child = yield* spawner.spawn(command); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ); - - return { stdout, stderr, code: exitCode } satisfies CommandResult; - }).pipe(Effect.scoped); - -// ── Health check ──────────────────────────────────────────────────── - -export const checkCodexProviderStatus: Effect.Effect< - ServerProviderStatus, - never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path -> = Effect.gen(function* () { - const checkedAt = new Date().toISOString(); - - // Probe 1: `codex --version` — is the CLI reachable? - const versionProbe = yield* runCodexCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: isCommandMissingCause(error) - ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }; - } - - if (Option.isNone(versionProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Codex CLI is installed but failed to run. Timed out while running command.", - }; - } - - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Codex CLI is installed but failed to run. ${detail}` - : "Codex CLI is installed but failed to run.", - }; - } - - const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`); - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - return { - provider: CODEX_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: formatCodexCliUpgradeMessage(parsedVersion), - }; - } - - // Probe 2: `codex login status` — is the user authenticated? - // - // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle - // authentication through their own environment variables, so `codex - // login status` will report "not logged in" even when the CLI works - // fine. Skip the auth probe entirely for non-OpenAI providers. - if (yield* hasCustomModelProvider) { - return { - provider: CODEX_PROVIDER, - status: "ready" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Using a custom Codex model provider; OpenAI login check skipped.", - } satisfies ServerProviderStatus; - } - - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", - }; - } - - if (Option.isNone(authProbe.success)) { - return { - provider: CODEX_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Could not verify Codex authentication status. Timed out while running command.", - }; - } - - const parsed = parseAuthStatusFromOutput(authProbe.success.value); - return { - provider: CODEX_PROVIDER, - status: parsed.status, - available: true, - authStatus: parsed.authStatus, - checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), - } satisfies ServerProviderStatus; -}); - -// ── Claude Agent health check ─────────────────────────────────────── - -export function parseClaudeAuthStatusFromOutput(result: CommandResult): { - readonly status: ServerProviderStatusState; - readonly authStatus: ServerProviderAuthStatus; - readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { - return { - status: "warning", - authStatus: "unknown", - message: - "Claude Agent authentication status command is unavailable in this version of Claude.", - }; - } - - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `claude login`") || - lowerOutput.includes("run claude login") - ) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - - // `claude auth status` returns JSON with a `loggedIn` boolean. - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; - } - if (parsedAuth.auth === false) { - return { - status: "error", - authStatus: "unauthenticated", - message: "Claude is not authenticated. Run `claude auth login` and try again.", - }; - } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - authStatus: "unknown", - message: - "Could not verify Claude authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; - } - - const detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Claude authentication status. ${detail}` - : "Could not verify Claude authentication status.", - }; -} - -export const checkClaudeProviderStatus: Effect.Effect< - ServerProviderStatus, - never, - ChildProcessSpawner.ChildProcessSpawner -> = Effect.gen(function* () { - const checkedAt = new Date().toISOString(); - - // Probe 1: `claude --version` — is the CLI reachable? - const versionProbe = yield* runClaudeCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }; - } - - if (Option.isNone(versionProbe.success)) { - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: "Claude Agent CLI is installed but failed to run. Timed out while running command.", - }; - } - - const version = versionProbe.success.value; - if (version.code !== 0) { - const detail = detailFromResult(version); - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "error" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", - }; - } - - // Probe 2: `claude auth status` — is the user authenticated? - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: - error instanceof Error - ? `Could not verify Claude authentication status: ${error.message}.` - : "Could not verify Claude authentication status.", - }; - } - - if (Option.isNone(authProbe.success)) { - return { - provider: CLAUDE_AGENT_PROVIDER, - status: "warning" as const, - available: true, - authStatus: "unknown" as const, - checkedAt, - message: "Could not verify Claude authentication status. Timed out while running command.", - }; - } - - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); - return { - provider: CLAUDE_AGENT_PROVIDER, - status: parsed.status, - available: true, - authStatus: parsed.authStatus, - checkedAt, - ...(parsed.message ? { message: parsed.message } : {}), - } satisfies ServerProviderStatus; -}); - -// ── Layer ─────────────────────────────────────────────────────────── - -export const ProviderHealthLive = Layer.effect( - ProviderHealth, - Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }).pipe(Effect.forkScoped); - - return { - getStatuses: Fiber.join(statusesFiber), - } satisfies ProviderHealthShape; - }), -); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts new file mode 100644 index 0000000000..bed25977d6 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -0,0 +1,879 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, it, assert } from "@effect/vitest"; +import { + Effect, + Exit, + FileSystem, + Layer, + Path, + PubSub, + Ref, + Schema, + Scope, + Sink, + Stream, +} from "effect"; +import { + DEFAULT_SERVER_SETTINGS, + ServerSettings, + type ServerProvider, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { deepMerge } from "@t3tools/shared/Struct"; + +import { + checkCodexProviderStatus, + hasCustomModelProvider, + parseAuthStatusFromOutput, + readCodexConfigModelProvider, +} from "./CodexProvider"; +import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider"; +import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings"; +import { ProviderRegistry } from "../Services/ProviderRegistry"; + +// ── Test helpers ──────────────────────────────────────────────────── + +const encoder = new TextEncoder(); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +function mockCommandSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { command: string; args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.command, cmd.args))); + }), + ); +} + +function failingSpawnerLayer(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); +} + +function makeMutableServerSettingsService( + initial: ContractServerSettings = DEFAULT_SERVER_SETTINGS, +) { + return Effect.gen(function* () { + const settingsRef = yield* Ref.make(initial); + const changes = yield* PubSub.unbounded(); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settingsRef), + updateSettings: (patch) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = Schema.decodeSync(ServerSettings)(deepMerge(current, patch)); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), + streamChanges: Stream.fromPubSub(changes), + } satisfies ServerSettingsShape; + }); +} + +/** + * Create a temporary CODEX_HOME scoped to the current Effect test. + * Cleanup is registered in the test scope rather than via Vitest hooks. + */ +function withTempCodexHome(configContent?: string) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" }); + + yield* Effect.acquireRelease( + Effect.sync(() => { + const originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = tmpDir; + return originalCodexHome; + }), + (originalCodexHome) => + Effect.sync(() => { + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + }), + ); + + if (configContent !== undefined) { + yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent); + } + + return { tmpDir } as const; + }); +} + +it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( + "ProviderRegistry", + (it) => { + // ── checkCodexProviderStatus tests ──────────────────────────────── + // + // These tests control CODEX_HOME to ensure the custom-provider detection + // in hasCustomModelProvider() does not interfere with the auth-probe + // path being tested. + + describe("checkCodexProviderStatus", () => { + it.effect("returns ready when codex is installed and authenticated", () => + Effect.gen(function* () { + // Point CODEX_HOME at an empty tmp dir (no config.toml) so the + // default code path (OpenAI provider, auth probe runs) is exercised. + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("inherits PATH when launching the codex probe with a CODEX_HOME override", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-test-codex-bin-", + }); + const codexPath = path.join(binDir, "codex"); + yield* fileSystem.writeFileString( + codexPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' echo "codex-cli 1.0.0"', + " exit 0", + "fi", + 'if [ "$1" = "login" ] && [ "$2" = "status" ]; then', + ' echo "Logged in using ChatGPT"', + " exit 0", + "fi", + 'echo "unexpected args: $*" >&2', + "exit 1", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(codexPath, 0o755); + const customCodexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-test-codex-home-", + }); + const previousPath = process.env.PATH; + process.env.PATH = binDir; + + try { + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + codex: { + homePath: customCodexHome, + }, + }, + }); + + const status = yield* checkCodexProviderStatus().pipe( + Effect.provide(serverSettingsLayer), + ); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.authStatus, "authenticated"); + } finally { + process.env.PATH = previousPath; + } + }), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI (`codex`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + + it.effect("returns unavailable when codex is below the minimum supported version", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth probe reports login required", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when login status output includes 'not logged in'", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when login status command is unsupported", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI authentication status command is unavailable in this Codex version.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + describe("ProviderRegistryLive", () => { + it("treats equal provider snapshots as unchanged", () => { + const providers = [ + { + provider: "codex", + status: "ready", + enabled: true, + installed: true, + authStatus: "authenticated", + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + }, + { + provider: "claudeAgent", + status: "warning", + enabled: true, + installed: true, + authStatus: "unknown", + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + models: [], + }, + ] as const satisfies ReadonlyArray; + + assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); + }); + + it.effect("reruns codex health when codex provider settings change", () => + Effect.gen(function* () { + const serverSettings = yield* makeMutableServerSettingsService(); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(Layer.succeed(ServerSettingsService, serverSettings)), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "spawn ENOENT", code: 1 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ); + const runtimeServices = yield* Layer.build( + Layer.mergeAll( + Layer.succeed(ServerSettingsService, serverSettings), + providerRegistryLayer, + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + + const initial = yield* registry.getProviders; + assert.strictEqual( + initial.find((status) => status.provider === "codex")?.status, + "ready", + ); + + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/custom/codex", + }, + }, + }); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const updated = yield* registry.getProviders; + if (updated.find((status) => status.provider === "codex")?.status === "error") { + return; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + } + + const updated = yield* registry.getProviders; + assert.strictEqual( + updated.find((status) => status.provider === "codex")?.status, + "error", + ); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + + it.effect("skips codex probes entirely when the provider is disabled", () => + Effect.gen(function* () { + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + codex: { + enabled: false, + }, + }, + }); + + const status = yield* checkCodexProviderStatus().pipe( + Effect.provide( + Layer.mergeAll(serverSettingsLayer, failingSpawnerLayer("spawn codex ENOENT")), + ), + ); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.enabled, false); + assert.strictEqual(status.status, "disabled"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.message, "Codex is disabled in T3 Code settings."); + }), + ); + }); + + // ── Custom model provider: checkCodexProviderStatus integration ─── + + describe("checkCodexProviderStatus with custom model provider", () => { + it.effect( + "skips auth probe and returns ready when a custom model provider is configured", + () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "portkey"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'env_key = "PORTKEY_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Using a custom Codex model provider; OpenAI login check skipped.", + ); + }).pipe( + Effect.provide( + // The spawner only handles --version; if the test attempts + // "login status" the throw proves the auth probe was NOT skipped. + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), + ), + ); + + it.effect("still reports error when codex CLI is missing even with custom provider", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model_provider = "portkey"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'env_key = "PORTKEY_API_KEY"', + ].join("\n"), + ); + const status = yield* checkCodexProviderStatus(); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + }); + + describe("checkCodexProviderStatus with openai model provider", () => { + it.effect("still runs auth probe when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + const status = yield* checkCodexProviderStatus(); + // The auth probe runs and sees "not logged in" → error + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseAuthStatusFromOutput pure tests ────────────────────────── + + describe("parseAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with authenticated=false is unauthenticated", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"authenticated":false}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"ok":true}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); + + // ── readCodexConfigModelProvider tests ───────────────────────────── + + describe("readCodexConfigModelProvider", () => { + it.effect("returns undefined when config file does not exist", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); + }), + ); + + it.effect("returns undefined when config has no model_provider key", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); + }), + ); + + it.effect("returns the provider when model_provider is set at top level", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider(), "portkey"); + }), + ); + + it.effect("returns openai when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + assert.strictEqual(yield* readCodexConfigModelProvider(), "openai"); + }), + ); + + it.effect("ignores model_provider inside section headers", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + 'model = "gpt-5-codex"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'model_provider = "should-be-ignored"', + "", + ].join("\n"), + ); + assert.strictEqual(yield* readCodexConfigModelProvider(), undefined); + }), + ); + + it.effect("handles comments and whitespace", () => + Effect.gen(function* () { + yield* withTempCodexHome( + [ + "# This is a comment", + "", + ' model_provider = "azure" ', + "", + "[profiles.deep-review]", + 'model = "gpt-5-pro"', + ].join("\n"), + ); + assert.strictEqual(yield* readCodexConfigModelProvider(), "azure"); + }), + ); + + it.effect("handles single-quoted values in TOML", () => + Effect.gen(function* () { + yield* withTempCodexHome("model_provider = 'mistral'\n"); + assert.strictEqual(yield* readCodexConfigModelProvider(), "mistral"); + }), + ); + }); + + // ── hasCustomModelProvider tests ─────────────────────────────────── + + describe("hasCustomModelProvider", () => { + it.effect("returns false when no config file exists", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns false when model_provider is not set", () => + Effect.gen(function* () { + yield* withTempCodexHome('model = "gpt-5-codex"\n'); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns false when model_provider is openai", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "openai"\n'); + assert.strictEqual(yield* hasCustomModelProvider, false); + }), + ); + + it.effect("returns true when model_provider is portkey", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "portkey"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is azure", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "azure"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is ollama", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "ollama"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + + it.effect("returns true when model_provider is a custom proxy", () => + Effect.gen(function* () { + yield* withTempCodexHome('model_provider = "my-company-proxy"\n'); + assert.strictEqual(yield* hasCustomModelProvider, true); + }), + ); + }); + + // ── checkClaudeProviderStatus tests ────────────────────────── + + describe("checkClaudeProviderStatus", () => { + it.effect("returns ready when claude is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unavailable when claude is missing", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent CLI (`claude`) is not installed or not on PATH.", + ); + }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), + ); + + it.effect("returns error when version check fails with non-zero exit code", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") + return { stdout: "", stderr: "Something went wrong", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when auth status reports not logged in", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Claude is not authenticated. Run `claude auth login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns unauthenticated when output includes 'not logged in'", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when auth status command is unsupported", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Claude Agent authentication status command is unavailable in this version of Claude.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "", stderr: "error: unknown command 'auth'", code: 2 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + + // ── parseClaudeAuthStatusFromOutput pure tests ──────────────────── + + describe("parseClaudeAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=true is authenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); + + it("JSON with loggedIn=false is unauthenticated", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"loggedIn":false}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); + + it("JSON without auth marker is warning", () => { + const parsed = parseClaudeAuthStatusFromOutput({ + stdout: '{"ok":true}\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); + }); + }, +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts new file mode 100644 index 0000000000..1e66ce8ff5 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -0,0 +1,93 @@ +/** + * ProviderRegistryLive - Aggregates provider-specific snapshot services. + * + * @module ProviderRegistryLive + */ +import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; + +import { ClaudeProviderLive } from "./ClaudeProvider"; +import { CodexProviderLive } from "./CodexProvider"; +import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; +import { ClaudeProvider } from "../Services/ClaudeProvider"; +import type { CodexProviderShape } from "../Services/CodexProvider"; +import { CodexProvider } from "../Services/CodexProvider"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; + +const loadProviders = ( + codexProvider: CodexProviderShape, + claudeProvider: ClaudeProviderShape, +): Effect.Effect => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + concurrency: "unbounded", + }); + +export const haveProvidersChanged = ( + previousProviders: ReadonlyArray, + nextProviders: ReadonlyArray, +): boolean => !Equal.equals(previousProviders, nextProviders); + +export const ProviderRegistryLive = Layer.effect( + ProviderRegistry, + Effect.gen(function* () { + const codexProvider = yield* CodexProvider; + const claudeProvider = yield* ClaudeProvider; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded>(), + PubSub.shutdown, + ); + const providersRef = yield* Ref.make>( + yield* loadProviders(codexProvider, claudeProvider), + ); + + const syncProviders = (options?: { readonly publish?: boolean }) => + Effect.gen(function* () { + const previousProviders = yield* Ref.get(providersRef); + const providers = yield* loadProviders(codexProvider, claudeProvider); + yield* Ref.set(providersRef, providers); + + if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + + return providers; + }); + + yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); + yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); + + return { + getProviders: syncProviders({ publish: false }).pipe( + Effect.tapError(Effect.logError), + Effect.orElseSucceed(() => []), + ), + refresh: (provider?: ProviderKind) => + Effect.gen(function* () { + switch (provider) { + case "codex": + yield* codexProvider.refresh; + break; + case "claudeAgent": + yield* claudeProvider.refresh; + break; + default: + yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { + concurrency: "unbounded", + }); + break; + } + return yield* syncProviders(); + }).pipe( + Effect.tapError(Effect.logError), + Effect.orElseSucceed(() => []), + ), + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ProviderRegistryShape; + }), +).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 7af85aafd2..651a611649 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -42,8 +42,11 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +const defaultServerSettingsLayer = ServerSettingsService.layerTest(); + const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -251,6 +254,7 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), ), directoryLayer, @@ -267,6 +271,55 @@ function makeProviderServiceLayer() { }; } +it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const claude = makeFakeCodexAdapter("claudeAgent"); + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codex.adapter) + : provider === "claudeAgent" + ? Effect.succeed(claude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeAgent"]), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { + enabled: false, + }, + }, + }); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(serverSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + const failure = yield* Effect.flip( + Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-disabled"), { + provider: "claudeAgent", + threadId: asThreadId("thread-disabled"), + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(providerLayer)), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, "Provider 'claudeAgent' is disabled in T3 Code settings."); + assert.equal(claude.startSession.mock.calls.length, 0); + }).pipe(Effect.provide(NodeServices.layer)), +); + const routing = makeProviderServiceLayer(); it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { @@ -299,6 +352,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -358,6 +412,7 @@ it.effect( const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); const updatedResumeCursor = { @@ -409,6 +464,7 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -769,6 +825,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -801,6 +858,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 364e30fd0e..0137152e83 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -33,6 +33,7 @@ import { } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -91,7 +92,6 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; - readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -102,7 +102,6 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), - ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } @@ -120,17 +119,6 @@ function readPersistedModelSelection( return Schema.is(ModelSelection)(raw) ? raw : undefined; } -function readPersistedProviderOptions( - runtimePayload: ProviderRuntimeBinding["runtimePayload"], -): Record | undefined { - if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { - return undefined; - } - const raw = "providerOptions" in runtimePayload ? runtimePayload.providerOptions : undefined; - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - return raw as Record; -} - function readPersistedCwd( runtimePayload: ProviderRuntimeBinding["runtimePayload"], ): string | undefined { @@ -146,6 +134,7 @@ function readPersistedCwd( const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const analytics = yield* Effect.service(AnalyticsService); + const serverSettings = yield* ServerSettingsService; const canonicalEventLogger = options?.canonicalEventLogger ?? (options?.canonicalEventLogPath !== undefined @@ -173,7 +162,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId: ThreadId, extra?: { readonly modelSelection?: unknown; - readonly providerOptions?: unknown; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -239,14 +227,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); @@ -308,6 +294,21 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => threadId, provider: parsed.provider ?? "codex", }; + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((error) => + toValidationError( + "ProviderService.startSession", + `Failed to load provider settings: ${error.message}`, + error, + ), + ), + ); + if (!settings.providers[input.provider].enabled) { + return yield* toValidationError( + "ProviderService.startSession", + `Provider '${input.provider}' is disabled in T3 Code settings.`, + ); + } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); const effectiveResumeCursor = input.resumeCursor ?? @@ -329,7 +330,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* upsertSessionBinding(session, threadId, { modelSelection: input.modelSelection, - providerOptions: input.providerOptions, }); yield* analytics.record("provider.session.started", { provider: session.provider, diff --git a/apps/server/src/provider/Services/ClaudeProvider.ts b/apps/server/src/provider/Services/ClaudeProvider.ts new file mode 100644 index 0000000000..18ee8a4f6d --- /dev/null +++ b/apps/server/src/provider/Services/ClaudeProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface ClaudeProviderShape extends ServerProviderShape {} + +export class ClaudeProvider extends ServiceMap.Service()( + "t3/provider/Services/ClaudeProvider", +) {} diff --git a/apps/server/src/provider/Services/CodexProvider.ts b/apps/server/src/provider/Services/CodexProvider.ts new file mode 100644 index 0000000000..2e9b57c89b --- /dev/null +++ b/apps/server/src/provider/Services/CodexProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface CodexProviderShape extends ServerProviderShape {} + +export class CodexProvider extends ServiceMap.Service()( + "t3/provider/Services/CodexProvider", +) {} diff --git a/apps/server/src/provider/Services/ProviderHealth.ts b/apps/server/src/provider/Services/ProviderHealth.ts deleted file mode 100644 index ec3b2d318d..0000000000 --- a/apps/server/src/provider/Services/ProviderHealth.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * ProviderHealth - Provider readiness snapshot service. - * - * Owns provider health checks (install/auth reachability) and exposes the - * latest results to transport layers. - * - * @module ProviderHealth - */ -import type { ServerProviderStatus } from "@t3tools/contracts"; -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -export interface ProviderHealthShape { - /** - * Read the latest provider health statuses. - */ - readonly getStatuses: Effect.Effect>; -} - -export class ProviderHealth extends ServiceMap.Service()( - "t3/provider/Services/ProviderHealth", -) {} diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts new file mode 100644 index 0000000000..80710691c1 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -0,0 +1,32 @@ +/** + * ProviderRegistry - Provider snapshot service. + * + * Owns provider install/auth/version/model snapshots and exposes the latest + * provider state to transport layers. + * + * @module ProviderRegistry + */ +import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; + +export interface ProviderRegistryShape { + /** + * Read the latest provider snapshots. + */ + readonly getProviders: Effect.Effect>; + + /** + * Refresh all providers, or a single provider when specified. + */ + readonly refresh: (provider?: ProviderKind) => Effect.Effect>; + + /** + * Stream of provider snapshot updates. + */ + readonly streamChanges: Stream.Stream>; +} + +export class ProviderRegistry extends ServiceMap.Service()( + "t3/provider/Services/ProviderRegistry", +) {} diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts new file mode 100644 index 0000000000..4df0bc8fc2 --- /dev/null +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -0,0 +1,8 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import type { Effect, Stream } from "effect"; + +export interface ServerProviderShape { + readonly getSnapshot: Effect.Effect; + readonly refresh: Effect.Effect; + readonly streamChanges: Stream.Stream; +} diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts new file mode 100644 index 0000000000..e519e82af5 --- /dev/null +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -0,0 +1,72 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { Duration, Effect, PubSub, Ref, Scope, Stream } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import type { ServerProviderShape } from "./Services/ServerProvider"; +import { ServerSettingsError } from "../serverSettings"; + +export function makeManagedServerProvider(input: { + readonly getSettings: Effect.Effect; + readonly streamSettings: Stream.Stream; + readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; + readonly checkProvider: Effect.Effect; + readonly refreshInterval?: Duration.Input; +}): Effect.Effect { + return Effect.gen(function* () { + const refreshSemaphore = yield* Semaphore.make(1); + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + const initialSettings = yield* input.getSettings; + const initialSnapshot = yield* input.checkProvider; + const snapshotRef = yield* Ref.make(initialSnapshot); + const settingsRef = yield* Ref.make(initialSettings); + + const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => + refreshSemaphore.withPermits(1)( + Effect.gen(function* () { + const forceRefresh = options?.forceRefresh === true; + const previousSettings = yield* Ref.get(settingsRef); + if (!forceRefresh && !input.haveSettingsChanged(previousSettings, nextSettings)) { + yield* Ref.set(settingsRef, nextSettings); + return yield* Ref.get(snapshotRef); + } + + const nextSnapshot = yield* input.checkProvider; + yield* Ref.set(settingsRef, nextSettings); + yield* Ref.set(snapshotRef, nextSnapshot); + yield* PubSub.publish(changesPubSub, nextSnapshot); + return nextSnapshot; + }), + ); + + const refreshSnapshot = Effect.gen(function* () { + const nextSettings = yield* input.getSettings; + return yield* applySnapshot(nextSettings, { forceRefresh: true }); + }); + + yield* Stream.runForEach(input.streamSettings, (nextSettings) => + Effect.asVoid(applySnapshot(nextSettings)), + ).pipe(Effect.forkScoped); + + yield* Effect.forever( + Effect.sleep(input.refreshInterval ?? "60 seconds").pipe( + Effect.flatMap(() => refreshSnapshot), + Effect.ignoreCause({ log: true }), + ), + ).pipe(Effect.forkScoped); + + return { + getSnapshot: input.getSettings.pipe( + Effect.flatMap(applySnapshot), + Effect.tapError(Effect.logError), + Effect.orDie, + ), + refresh: refreshSnapshot.pipe(Effect.tapError(Effect.logError), Effect.orDie), + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ServerProviderShape; + }); +} diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts new file mode 100644 index 0000000000..19111b0485 --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.ts @@ -0,0 +1,134 @@ +import type { + ServerProvider, + ServerProviderAuthStatus, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { Effect, Stream } from "effect"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +export const DEFAULT_TIMEOUT_MS = 4_000; + +export interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface ProviderProbeResult { + readonly installed: boolean; + readonly version: string | null; + readonly status: Exclude; + readonly authStatus: ServerProviderAuthStatus; + readonly message?: string; +} + +export function nonEmptyTrimmed(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function isCommandMissingCause(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const lower = error.message.toLowerCase(); + return lower.includes("enoent") || lower.includes("notfound"); +} + +export function detailFromResult( + result: CommandResult & { readonly timedOut?: boolean }, +): string | undefined { + if (result.timedOut) return "Timed out while running command."; + const stderr = nonEmptyTrimmed(result.stderr); + if (stderr) return stderr; + const stdout = nonEmptyTrimmed(result.stdout); + if (stdout) return stdout; + if (result.code !== 0) { + return `Command exited with code ${result.code}.`; + } + return undefined; +} + +export function extractAuthBoolean(value: unknown): boolean | undefined { + if (globalThis.Array.isArray(value)) { + for (const entry of value) { + const nested = extractAuthBoolean(entry); + if (nested !== undefined) return nested; + } + return undefined; + } + + if (!value || typeof value !== "object") return undefined; + + const record = value as Record; + for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { + if (typeof record[key] === "boolean") return record[key]; + } + for (const key of ["auth", "status", "session", "account"] as const) { + const nested = extractAuthBoolean(record[key]); + if (nested !== undefined) return nested; + } + return undefined; +} + +export function parseGenericCliVersion(output: string): string | null { + const match = output.match(/\b(\d+\.\d+\.\d+)\b/); + return match?.[1] ?? null; +} + +export function providerModelsFromSettings( + builtInModels: ReadonlyArray, + provider: ServerProvider["provider"], + customModels: ReadonlyArray, +): ReadonlyArray { + const resolvedBuiltInModels = [...builtInModels]; + const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); + const customEntries: ServerProviderModel[] = []; + + for (const candidate of customModels) { + const normalized = normalizeModelSlug(candidate, provider); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + customEntries.push({ + slug: normalized, + name: normalized, + isCustom: true, + capabilities: null, + }); + } + + return [...resolvedBuiltInModels, ...customEntries]; +} + +export function buildServerProvider(input: { + provider: ServerProvider["provider"]; + enabled: boolean; + checkedAt: string; + models: ReadonlyArray; + probe: ProviderProbeResult; +}): ServerProvider { + return { + provider: input.provider, + enabled: input.enabled, + installed: input.probe.installed, + version: input.probe.version, + status: input.enabled ? input.probe.status : "disabled", + authStatus: input.probe.authStatus, + checkedAt: input.checkedAt, + ...(input.probe.message ? { message: input.probe.message } : {}), + models: input.models, + }; +} + +export const collectStreamAsString = ( + stream: Stream.Stream, +): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 68fa9e8708..a8c1a13f7f 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -24,6 +24,7 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; +import { ServerSettingsService } from "./serverSettings"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; @@ -54,7 +55,11 @@ const makeRuntimePtyAdapterLayer = () => export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService + | SqlClient.SqlClient + | ServerConfig + | ServerSettingsService + | FileSystem.FileSystem + | AnalyticsService > { return Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts new file mode 100644 index 0000000000..f26fece246 --- /dev/null +++ b/apps/server/src/serverSettings.test.ts @@ -0,0 +1,182 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DEFAULT_SERVER_SETTINGS, ServerSettingsPatch } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Schema } from "effect"; +import { ServerConfig } from "./config"; +import { ServerSettingsLive, ServerSettingsService } from "./serverSettings"; + +const makeServerSettingsLayer = () => + ServerSettingsLive.pipe( + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-test-", + }), + ), + ), + ); + +it.layer(NodeServices.layer)("server settings", (it) => { + it.effect("decodes nested settings patches", () => + Effect.sync(() => { + const decodePatch = Schema.decodeUnknownSync(ServerSettingsPatch); + + assert.deepEqual(decodePatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), { + providers: { codex: { binaryPath: "/tmp/codex" } }, + }); + + assert.deepEqual( + decodePatch({ + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }), + { + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }, + ); + }), + ); + + it.effect("deep merges nested settings updates without dropping siblings", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/usr/local/bin/codex", + homePath: "/Users/julius/.codex", + }, + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + customModels: ["claude-custom"], + }, + }, + textGenerationModelSelection: { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/opt/homebrew/bin/codex", + }, + }, + textGenerationModelSelection: { + options: { + fastMode: false, + }, + }, + }); + + assert.deepEqual(next.providers.codex, { + enabled: true, + binaryPath: "/opt/homebrew/bin/codex", + homePath: "/Users/julius/.codex", + customModels: [], + }); + assert.deepEqual(next.providers.claudeAgent, { + enabled: true, + binaryPath: "/usr/local/bin/claude", + customModels: ["claude-custom"], + }); + assert.deepEqual(next.textGenerationModelSelection, { + provider: "codex", + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, + options: { + reasoningEffort: "high", + fastMode: false, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("trims provider path settings when updates are applied", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: " /opt/homebrew/bin/codex ", + homePath: " ", + }, + claudeAgent: { + binaryPath: " /opt/homebrew/bin/claude ", + }, + }, + }); + + assert.deepEqual(next.providers.codex, { + enabled: true, + binaryPath: "/opt/homebrew/bin/codex", + homePath: "", + customModels: [], + }); + assert.deepEqual(next.providers.claudeAgent, { + enabled: true, + binaryPath: "/opt/homebrew/bin/claude", + customModels: [], + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("defaults blank binary paths to provider executables", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: " ", + }, + claudeAgent: { + binaryPath: "", + }, + }, + }); + + assert.equal(next.providers.codex.binaryPath, "codex"); + assert.equal(next.providers.claudeAgent.binaryPath, "claude"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("writes only non-default server settings to disk", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const next = yield* serverSettings.updateSettings({ + providers: { + codex: { + binaryPath: "/opt/homebrew/bin/codex", + }, + }, + }); + + assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.deepEqual(JSON.parse(raw), { + providers: { + codex: { + binaryPath: "/opt/homebrew/bin/codex", + }, + }, + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); +}); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts new file mode 100644 index 0000000000..f638e7fdfa --- /dev/null +++ b/apps/server/src/serverSettings.ts @@ -0,0 +1,339 @@ +/** + * ServerSettings - Server-authoritative settings service. + * + * Owns persistence, validation, and change notification of settings that affect + * server-side behavior (binary paths, streaming mode, env mode, custom models, + * text generation model selection). + * + * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + + * Semaphore + FileSystem.watch for concurrency and external edit detection. + * + * @module ServerSettings + */ +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + DEFAULT_SERVER_SETTINGS, + type ModelSelection, + type ProviderKind, + ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { + Cache, + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Path, + PubSub, + Ref, + Schema, + SchemaIssue, + Scope, + ServiceMap, + Stream, +} from "effect"; +import * as Semaphore from "effect/Semaphore"; +import { ServerConfig } from "./config"; +import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; + +export class ServerSettingsError extends Schema.TaggedErrorClass()( + "ServerSettingsError", + { + settingsPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Server settings error at ${this.settingsPath}: ${this.detail}`; + } +} + +export interface ServerSettingsShape { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; + + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; + + /** Read the current settings. */ + readonly getSettings: Effect.Effect; + + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; + + /** Stream of settings change events. */ + readonly streamChanges: Stream.Stream; +} + +export class ServerSettingsService extends ServiceMap.Service< + ServerSettingsService, + ServerSettingsShape +>()("t3/serverSettings/ServerSettingsService") { + static readonly layerTest = (overrides: DeepPartial = {}) => + Layer.effect( + ServerSettingsService, + Effect.gen(function* () { + const currentSettingsRef = yield* Ref.make( + deepMerge(DEFAULT_SERVER_SETTINGS, overrides), + ); + + return { + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(currentSettingsRef), + updateSettings: (patch) => + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => deepMerge(currentSettings, patch)), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + ), + streamChanges: Stream.empty, + } satisfies ServerSettingsShape; + }), + ); +} + +const ServerSettingsJson = fromLenientJson(ServerSettings); + +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; + +/** + * Ensure the `textGenerationModelSelection` points to an enabled provider. + * If the selected provider is disabled, fall back to the first enabled + * provider with its default model. This is applied at read-time so the + * persisted preference is preserved for when a provider is re-enabled. + */ +function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { + const selection = settings.textGenerationModelSelection; + if (settings.providers[selection.provider].enabled) { + return settings; + } + + const fallback = PROVIDER_ORDER.find((p) => settings.providers[p].enabled); + if (!fallback) { + // No providers enabled — return as-is; callers will report the error. + return settings; + } + + return { + ...settings, + textGenerationModelSelection: { + provider: fallback, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[fallback], + } as ModelSelection, + }; +} + +function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { + if (Array.isArray(current) || Array.isArray(defaults)) { + return JSON.stringify(current) === JSON.stringify(defaults) ? undefined : current; + } + + if ( + current !== null && + defaults !== null && + typeof current === "object" && + typeof defaults === "object" + ) { + const currentRecord = current as Record; + const defaultsRecord = defaults as Record; + const next: Record = {}; + + for (const key of Object.keys(currentRecord)) { + const stripped = stripDefaultServerSettings(currentRecord[key], defaultsRecord[key]); + if (stripped !== undefined) { + next[key] = stripped; + } + } + + return Object.keys(next).length > 0 ? next : undefined; + } + + return Object.is(current, defaults) ? undefined : current; +} + +const makeServerSettings = Effect.gen(function* () { + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const writeSemaphore = yield* Semaphore.make(1); + const cacheKey = "settings" as const; + const changesPubSub = yield* PubSub.unbounded(); + const startedRef = yield* Ref.make(false); + const startedDeferred = yield* Deferred.make(); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + const emitChange = (settings: ServerSettings) => + PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); + + const readConfigExists = fs.exists(settingsPath).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to check settings file existence", + cause, + }), + ), + ); + + const readRawConfig = fs.readFileString(settingsPath).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to read settings file", + cause, + }), + ), + ); + + const loadSettingsFromDisk = Effect.gen(function* () { + if (!(yield* readConfigExists)) { + return DEFAULT_SERVER_SETTINGS; + } + + const raw = yield* readRawConfig; + const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + }); + return DEFAULT_SERVER_SETTINGS; + } + return decoded.value; + }); + + const settingsCache = yield* Cache.make({ + capacity: 1, + lookup: () => loadSettingsFromDisk, + }); + + const getSettingsFromCache = Cache.get(settingsCache, cacheKey); + + const writeSettingsAtomically = (settings: ServerSettings) => { + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; + + return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( + Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, settingsPath)), + Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to write settings file", + cause, + }), + ), + ); + }; + + const revalidateAndEmit = writeSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Cache.invalidate(settingsCache, cacheKey); + const settings = yield* getSettingsFromCache; + yield* emitChange(settings); + }), + ); + + const startWatcher = Effect.gen(function* () { + const settingsDir = pathService.dirname(settingsPath); + const settingsFile = pathService.basename(settingsPath); + const settingsPathResolved = pathService.resolve(settingsPath); + + yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to prepare settings directory", + cause, + }), + ), + ); + + const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + + // Debounce watch events so the file is fully written before we read it. + // Editors emit multiple events per save (truncate, write, rename) and + // `fs.watch` can fire before the content has been flushed to disk. + const debouncedSettingsEvents = fs.watch(settingsDir).pipe( + Stream.filter((event) => { + return ( + event.path === settingsFile || + event.path === settingsPath || + pathService.resolve(settingsDir, event.path) === settingsPathResolved + ); + }), + Stream.debounce(Duration.millis(100)), + ); + + yield* Stream.runForEach(debouncedSettingsEvents, () => revalidateAndEmitSafely).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + Effect.asVoid, + ); + }); + + const start = Effect.gen(function* () { + const shouldStart = yield* Ref.modify(startedRef, (started) => [!started, true]); + if (!shouldStart) { + return yield* Deferred.await(startedDeferred); + } + + const startup = Effect.gen(function* () { + yield* startWatcher; + yield* Cache.invalidate(settingsCache, cacheKey); + yield* getSettingsFromCache; + }); + + const startupExit = yield* Effect.exit(startup); + if (startupExit._tag === "Failure") { + yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); + return yield* Effect.failCause(startupExit.cause); + } + + yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); + }); + + return { + start, + ready: Deferred.await(startedDeferred), + getSettings: getSettingsFromCache.pipe(Effect.map(resolveTextGenerationProvider)), + updateSettings: (patch) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath: "", + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, + cause, + }), + ), + ); + yield* writeSettingsAtomically(next); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + return resolveTextGenerationProvider(next); + }), + ), + get streamChanges() { + return Stream.fromPubSub(changesPubSub).pipe(Stream.map(resolveTextGenerationProvider)); + }, + } satisfies ServerSettingsShape; +}); + +export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 7dc4a59e7c..826b9ad6fd 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -13,18 +13,20 @@ import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serve import { DEFAULT_TERMINAL_ID, + DEFAULT_SERVER_SETTINGS, EDITORS, EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, ProviderItemId, + type ServerSettings, ThreadId, TurnId, WS_CHANNELS, WS_METHODS, type WebSocketResponse, type ProviderRuntimeEvent, - type ServerProviderStatus, + type ServerProvider, type KeybindingsConfig, type ResolvedKeybindingsConfig, type WsPushChannel, @@ -45,7 +47,7 @@ import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/ import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient, SqlError } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; -import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; +import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry"; import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; @@ -53,6 +55,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -64,20 +67,27 @@ const defaultOpenService: OpenShape = { openInEditor: () => Effect.void, }; -const defaultProviderStatuses: ReadonlyArray = [ +const defaultProviderStatuses: ReadonlyArray = [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: "2026-01-01T00:00:00.000Z", + models: [], }, ]; -const defaultProviderHealthService: ProviderHealthShape = { - getStatuses: Effect.succeed(defaultProviderStatuses), +const defaultProviderRegistryService: ProviderRegistryShape = { + getProviders: Effect.succeed(defaultProviderStatuses), + refresh: () => Effect.succeed(defaultProviderStatuses), + streamChanges: Stream.empty, }; +const defaultServerSettings = DEFAULT_SERVER_SETTINGS; + class MockTerminalManager implements TerminalManagerShape { private readonly sessions = new Map(); private readonly listeners = new Set<(event: TerminalEvent) => void>(); @@ -487,11 +497,12 @@ describe("WebSocket Server", () => { baseDir?: string; staticDir?: string; providerLayer?: Layer.Layer; - providerHealth?: ProviderHealthShape; + providerRegistry?: ProviderRegistryShape; open?: OpenShape; gitManager?: GitManagerShape; gitCore?: Pick; terminalManager?: TerminalManagerShape; + serverSettings?: Partial; } = {}, ): Promise { if (serverScope) { @@ -504,9 +515,9 @@ describe("WebSocket Server", () => { const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; const providerLayer = options.providerLayer ?? makeServerProviderLayer(); - const providerHealthLayer = Layer.succeed( - ProviderHealth, - options.providerHealth ?? defaultProviderHealthService, + const providerRegistryLayer = Layer.succeed( + ProviderRegistry, + options.providerRegistry ?? defaultProviderRegistryService, ); const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); const serverConfigLayer = Layer.succeed(ServerConfig, { @@ -543,8 +554,9 @@ describe("WebSocket Server", () => { ); const dependenciesLayer = Layer.empty.pipe( Layer.provideMerge(runtimeLayer), - Layer.provideMerge(providerHealthLayer), + Layer.provideMerge(providerRegistryLayer), Layer.provideMerge(openLayer), + Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provideMerge(NodeServices.layer), @@ -858,6 +870,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); @@ -883,6 +896,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); @@ -919,6 +933,7 @@ describe("WebSocket Server", () => { ], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); @@ -952,7 +967,7 @@ describe("WebSocket Server", () => { keybindingsConfigPath: string; keybindings: ResolvedKeybindingsConfig; issues: Array<{ kind: string; index?: number; message: string }>; - providers: ReadonlyArray; + providers: ReadonlyArray; availableEditors: unknown; }; expect(result.cwd).toBe("/my/workspace"); @@ -1000,7 +1015,6 @@ describe("WebSocket Server", () => { ); expect(malformedPush.data).toEqual({ issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], - providers: defaultProviderStatuses, }); const successPush = await rewriteKeybindingsAndWaitForPush( @@ -1009,7 +1023,7 @@ describe("WebSocket Server", () => { "[]", (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); + expect(successPush.data).toEqual({ issues: [] }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -1069,6 +1083,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); }); @@ -1117,6 +1132,7 @@ describe("WebSocket Server", () => { issues: [], providers: defaultProviderStatuses, availableEditors: expect.any(Array), + settings: defaultServerSettings, }); expectAvailableEditors( (configResponse.result as { availableEditors: unknown }).availableEditors, @@ -1273,6 +1289,7 @@ describe("WebSocket Server", () => { server = await createTestServer({ cwd: "/test", providerLayer, + serverSettings: { enableAssistantStreaming: true }, }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1323,7 +1340,6 @@ describe("WebSocket Server", () => { text: "hello", attachments: [], }, - assistantDeliveryMode: "streaming", runtimeMode: "approval-required", interactionMode: "default", createdAt, @@ -1850,10 +1866,6 @@ describe("WebSocket Server", () => { actionId: "client-action-1", cwd: "/test", action: "commit_push", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }, expect.objectContaining({ actionId: "client-action-1", diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..a4f6f987b6 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -49,12 +49,13 @@ import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; +import { ServerSettingsService } from "./serverSettings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ProviderService } from "./provider/Services/ProviderService"; -import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; @@ -208,7 +209,7 @@ export type ServerCoreRuntimeServices = | CheckpointDiffQuery | OrchestrationReactor | ProviderService - | ProviderHealth; + | ProviderRegistry; export type ServerRuntimeServices = | ServerCoreRuntimeServices @@ -216,6 +217,7 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings + | ServerSettingsService | Open | AnalyticsService; @@ -253,7 +255,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; - const providerHealth = yield* ProviderHealth; + const serverSettingsManager = yield* ServerSettingsService; + const providerRegistry = yield* ProviderRegistry; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -268,7 +271,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; + const providersRef = yield* Ref.make(yield* providerRegistry.getProviders); const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); yield* readiness.markKeybindingsReady; + yield* serverSettingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), + ), + ); const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; @@ -614,7 +622,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: event.issues, - providers: providerStatuses, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: [], + settings, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(providerRegistry.streamChanges, (providers) => + Effect.gen(function* () { + yield* Ref.set(providersRef, providers); + yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, { + providers, + }); }), ).pipe(Effect.forkIn(subscriptionsScope)); @@ -878,16 +901,26 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } - case WS_METHODS.serverGetConfig: + case WS_METHODS.serverGetConfig: { const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const settings = yield* serverSettingsManager.getSettings; + const providers = yield* Ref.get(providersRef); return { cwd, keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, - providers: providerStatuses, + providers, availableEditors, + settings, }; + } + + case WS_METHODS.serverRefreshProviders: { + const providers = yield* providerRegistry.refresh(); + yield* Ref.set(providersRef, providers); + return { providers }; + } case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); @@ -895,6 +928,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverGetSettings: { + return yield* serverSettingsManager.getSettings; + } + + case WS_METHODS.serverUpdateSettings: { + const body = stripRequestTag(request.body); + return yield* serverSettingsManager.updateSettings(body.patch); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/server/src/wsServer/pushBus.test.ts b/apps/server/src/wsServer/pushBus.test.ts index 172944607b..80e8be2185 100644 --- a/apps/server/src/wsServer/pushBus.test.ts +++ b/apps/server/src/wsServer/pushBus.test.ts @@ -53,7 +53,6 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "queued-before-connect" }], - providers: [], }); const delivered = yield* pushBus.publishClient( @@ -70,7 +69,6 @@ describe("makeServerPushBus", () => { yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { issues: [], - providers: [], }); yield* Effect.promise(() => client.waitForSentCount(2)); @@ -95,7 +93,6 @@ describe("makeServerPushBus", () => { channel: WS_CHANNELS.serverConfigUpdated, data: { issues: [], - providers: [], }, }); }), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts deleted file mode 100644 index fea74edd72..0000000000 --- a/apps/web/src/appSettings.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - AppSettingsSchema, - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - DEFAULT_TIMESTAMP_FORMAT, - getProviderStartOptions, -} from "./appSettings"; -import { - getAppModelOptions, - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - getCustomModelsForProvider, - getDefaultCustomModelsForProvider, - MODEL_PROVIDER_SETTINGS, - normalizeCustomModelSlugs, - patchCustomModels, - resolveAppModelSelectionState, - resolveAppModelSelection, -} from "./modelSelection"; - -describe("normalizeCustomModelSlugs", () => { - it("normalizes aliases, removes built-ins, and deduplicates values", () => { - expect( - normalizeCustomModelSlugs([ - " custom/internal-model ", - "gpt-5.3-codex", - "5.3", - "custom/internal-model", - "", - null, - ]), - ).toEqual(["custom/internal-model"]); - }); - - it("normalizes provider-specific aliases for claude", () => { - expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); - expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ - "claude/custom-sonnet", - ]); - }); -}); - -describe("getAppModelOptions", () => { - it("appends saved custom models after the built-in options", () => { - const options = getAppModelOptions("codex", ["custom/internal-model"]); - - expect(options.map((option) => option.slug)).toEqual([ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2-codex", - "gpt-5.2", - "custom/internal-model", - ]); - }); - - it("keeps the currently selected custom model available even if it is no longer saved", () => { - const options = getAppModelOptions("codex", [], "custom/selected-model"); - - expect(options.at(-1)).toEqual({ - slug: "custom/selected-model", - name: "custom/selected-model", - isCustom: true, - }); - }); - it("keeps a saved custom provider model available as an exact slug option", () => { - const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); - - expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( - true, - ); - }); -}); - -describe("resolveAppModelSelection", () => { - it("preserves saved custom model slugs instead of falling back to the default", () => { - expect( - resolveAppModelSelection( - "codex", - { codex: ["galapagos-alpha"], claudeAgent: [] }, - "galapagos-alpha", - ), - ).toBe("galapagos-alpha"); - }); - - it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); - }); - - it("resolves display names through the shared resolver", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( - "gpt-5.3-codex", - ); - }); - - it("resolves aliases through the shared resolver", () => { - expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( - "claude-sonnet-4-6", - ); - }); - - it("resolves transient selected custom models included in app model options", () => { - expect( - resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), - ).toBe("custom/selected-model"); - }); -}); - -describe("timestamp format defaults", () => { - it("defaults timestamp format to locale", () => { - expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); - }); -}); - -describe("sidebar sort defaults", () => { - it("defaults project sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at"); - }); - - it("defaults thread sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at"); - }); -}); - -describe("provider-specific custom models", () => { - it("includes provider-specific custom slugs in non-codex model lists", () => { - const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); - - expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); - }); -}); - -describe("getProviderStartOptions", () => { - it("returns only populated provider overrides", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "/usr/local/bin/claude", - codexBinaryPath: "", - codexHomePath: "/Users/you/.codex", - }), - ).toEqual({ - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - }, - codex: { - homePath: "/Users/you/.codex", - }, - }); - }); - - it("returns undefined when no provider overrides are configured", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "", - codexBinaryPath: "", - codexHomePath: "", - }), - ).toBeUndefined(); - }); -}); - -describe("provider-indexed custom model settings", () => { - const settings = { - customCodexModels: ["custom/codex-model"], - customClaudeModels: ["claude/custom-opus"], - } as const; - - it("exports one provider config per provider", () => { - expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ - "codex", - "claudeAgent", - ]); - }); - - it("reads custom models for each provider", () => { - expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); - expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); - }); - - it("reads default custom models for each provider", () => { - const defaults = { - customCodexModels: ["default/codex-model"], - customClaudeModels: ["claude/default-opus"], - } as const; - - expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); - expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ - "claude/default-opus", - ]); - }); - - it("patches custom models for codex", () => { - expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ - customCodexModels: ["custom/codex-model"], - }); - }); - - it("patches custom models for claude", () => { - expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ - customClaudeModels: ["claude/custom-opus"], - }); - }); - - it("builds a complete provider-indexed custom model record", () => { - expect(getCustomModelsByProvider(settings)).toEqual({ - codex: ["custom/codex-model"], - claudeAgent: ["claude/custom-opus"], - }); - }); - - it("builds provider-indexed model options including custom models", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); - - expect( - modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), - ).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), - ).toBe(true); - }); - - it("normalizes and deduplicates custom model options per provider", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider({ - customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], - customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], - }); - - expect( - modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), - ).toHaveLength(1); - expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), - ).toHaveLength(1); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), - ).toBe(true); - }); -}); - -describe("AppSettingsSchema", () => { - it("fills decoding defaults for persisted settings that predate newer keys", () => { - const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); - - expect( - decode( - JSON.stringify({ - codexBinaryPath: "/usr/local/bin/codex", - confirmThreadDelete: false, - }), - ), - ).toMatchObject({ - claudeBinaryPath: "", - codexBinaryPath: "/usr/local/bin/codex", - codexHomePath: "", - defaultThreadEnvMode: "local", - confirmThreadDelete: false, - enableAssistantStreaming: false, - sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - timestampFormat: DEFAULT_TIMESTAMP_FORMAT, - customCodexModels: [], - customClaudeModels: [], - }); - }); -}); - -describe("resolveAppModelSelectionState", () => { - it("falls back to the default git-writing codex selection", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: undefined, - }), - ).toEqual({ - provider: "codex", - model: "gpt-5.4-mini", - }); - }); - - it("preserves the selected provider and resolves saved custom models", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: ["claude/custom-haiku"], - textGenerationModelSelection: { - provider: "claudeAgent", - model: "claude/custom-haiku", - }, - }), - ).toEqual({ - provider: "claudeAgent", - model: "claude/custom-haiku", - }); - }); - - it("normalizes provider options against the resolved model capabilities", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "max", - thinking: false, - fastMode: true, - }, - }, - }), - ).toEqual({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }); - }); -}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts deleted file mode 100644 index e2aac52a84..0000000000 --- a/apps/web/src/appSettings.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { useCallback } from "react"; -import { Option, Schema } from "effect"; -import { - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - ModelSelection, - type ProviderStartOptions, -} from "@t3tools/contracts"; -import { useLocalStorage } from "./hooks/useLocalStorage"; -import { EnvMode } from "./components/BranchToolbar.logic"; -import { normalizeCustomModelSlugs } from "./modelSelection"; - -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; - -export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); -export type TimestampFormat = typeof TimestampFormat.Type; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); -export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; -export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; -export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); -export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; -export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; - -const withDefaults = - < - S extends Schema.Top & Schema.WithoutConstructorDefault, - D extends S["~type.make.in"] & S["Encoded"], - >( - fallback: () => D, - ) => - (schema: S) => - schema.pipe( - Schema.withConstructorDefault(() => Option.some(fallback())), - Schema.withDecodingDefault(() => fallback()), - ); - -export const AppSettingsSchema = Schema.Struct({ - claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), - confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), - diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), - enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), - sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), - ), - sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), - ), - timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), - customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - textGenerationModelSelection: ModelSelection.pipe( - withDefaults(() => ({ - provider: "codex" as const, - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - })), - ), -}); -export type AppSettings = typeof AppSettingsSchema.Type; - -const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); - -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), - }; -} - -export function getProviderStartOptions( - settings: Pick, -): ProviderStartOptions | undefined { - const providerOptions: ProviderStartOptions = { - ...(settings.codexBinaryPath || settings.codexHomePath - ? { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, - } - : {}), - ...(settings.claudeBinaryPath - ? { - claudeAgent: { - binaryPath: settings.claudeBinaryPath, - }, - } - : {}), - }; - - return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; -} - -export function useAppSettings() { - const [settings, setSettings] = useLocalStorage( - APP_SETTINGS_STORAGE_KEY, - DEFAULT_APP_SETTINGS, - AppSettingsSchema, - ); - - const updateSettings = useCallback( - (patch: Partial) => { - setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); - }, - [setSettings], - ); - - const resetSettings = useCallback(() => { - setSettings(DEFAULT_APP_SETTINGS); - }, [setSettings]); - - return { - settings, - updateSettings, - resetSettings, - defaults: DEFAULT_APP_SETTINGS, - } as const; -} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4e18092463..0995ae1ccc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -12,6 +12,7 @@ import { WS_CHANNELS, WS_METHODS, OrchestrationSessionStatus, + DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; @@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -110,13 +112,20 @@ function createBaseServerConfig(): ServerConfig { providers: [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: NOW_ISO, + models: [], }, ], availableEditors: [], + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c7aa583a0a..849f59e088 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -13,7 +13,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, - type ServerProviderStatus, + type ServerProvider, type ThreadId, type TurnId, type EditorId, @@ -22,11 +22,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getModelCapabilities, - normalizeModelSlug, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -120,12 +116,13 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getProviderStartOptions, useAppSettings } from "../appSettings"; import { - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - resolveAppModelSelection, -} from "../modelSelection"; + getProviderModelCapabilities, + getProviderModels, + resolveSelectableProvider, +} from "../providerModels"; +import { useSettings } from "../hooks/useSettings"; +import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -164,7 +161,7 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./chat/composerProviderRegistry"; -import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; +import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, @@ -191,16 +188,17 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; -const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; +const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function formatOutgoingPrompt(params: { provider: ProviderKind; model: string | null; + models: ReadonlyArray; effort: string | null; text: string; }): string { - const caps = getModelCapabilities(params.provider, params.model); + const caps = getProviderModelCapabilities(params.models, params.model, params.provider); if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); } @@ -249,7 +247,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); - const { settings } = useAppSettings(); + const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -608,25 +606,32 @@ export default function ChatView({ threadId }: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const selectedProvider: ProviderKind = - lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; - const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; + const unlockedSelectedProvider = resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider ?? "codex", + ); + const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, + providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, projectModelSelection: activeProject?.defaultModelSelection, - customModelsByProvider, + settings, }); + const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, prompt, modelOptions: composerModelOptions, }), - [composerModelOptions, prompt, selectedModel, selectedProvider], + [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -638,35 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); - const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; - const modelOptionsByProvider = useMemo( - () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel), - [settings, selectedProvider, selectedModel], - ); - const selectedModelForPickerWithCustomFallback = useMemo(() => { - const currentOptions = modelOptionsByProvider[selectedProvider]; - return currentOptions.some((option) => option.slug === selectedModelForPicker) - ? 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], - ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -1029,7 +1006,39 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const modelOptionsByProvider = useMemo( + () => ({ + codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], + claudeAgent: + providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + }), + [providerStatuses], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? 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], + ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, @@ -1117,9 +1126,6 @@ export default function ChatView({ threadId }: ChatViewProps) { () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); - const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; - const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; - const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -2460,6 +2466,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); @@ -2645,8 +2652,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2883,6 +2888,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: trimmed, }); @@ -2927,8 +2933,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -2974,11 +2978,10 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, + selectedProviderModels, setComposerDraftInteractionMode, setThreadError, - settings.enableAssistantStreaming, selectedModel, ], ); @@ -3005,6 +3008,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3044,8 +3048,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3096,9 +3098,8 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, - settings.enableAssistantStreaming, + selectedProviderModels, syncServerReadModel, selectedModel, ]); @@ -3110,9 +3111,15 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); + const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); + const resolvedModel = resolveAppModelSelection( + resolvedProvider, + settings, + providerStatuses, + model, + ); const nextModelSelection: ModelSelection = { - provider, + provider: resolvedProvider, model: resolvedModel, }; setComposerDraftModelSelection(activeThread.id, nextModelSelection); @@ -3125,7 +3132,8 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - customModelsByProvider, + providerStatuses, + settings, ], ); const setPromptFromTraits = useCallback( @@ -3148,6 +3156,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3156,6 +3165,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], prompt, onPromptChange: setPromptFromTraits, @@ -3519,7 +3529,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Error banner */} - + setThreadError(activeThread.id, null)} @@ -3796,6 +3806,7 @@ export default function ChatView({ threadId }: ChatViewProps) { provider={selectedProvider} model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} + providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} {...(composerProviderState.modelPickerIconClassName ? { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 96e0219872..fadb8cb69d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,7 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; -import { useAppSettings } from "../appSettings"; +import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -166,7 +166,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); - const { settings } = useAppSettings(); + const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const patchViewportRef = useRef(null); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4d34880e65..09b84444e2 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -20,7 +20,6 @@ import { resolveQuickAction, summarizeGitResult, } from "./GitActionsControl.logic"; -import { useAppSettings } from "~/appSettings"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -205,7 +204,6 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { } export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { - const { settings } = useAppSettings(); const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -261,7 +259,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient, - modelSelection: settings.textGenerationModelSelection, }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7cb55e795c..e64a981eec 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -46,13 +46,25 @@ function createBaseServerConfig(): ServerConfig { providers: [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: NOW_ISO, + models: [], }, ], availableEditors: [], + settings: { + enableAssistantStreaming: false, + defaultThreadEnvMode: "local" as const, + textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + providers: { + codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + }, + }, }; } diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 47bee930cc..01341dc803 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,5 +1,5 @@ import { memo, useState, useCallback } from "react"; -import { type TimestampFormat } from "../appSettings"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index de5859af92..6ca29d27e9 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,4 +1,4 @@ -import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "../appSettings"; +import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { Thread } from "../types"; import { cn } from "../lib/utils"; import { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 923c30b2f9..6c531b1e7a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,8 +41,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, - useAppSettings, -} from "../appSettings"; +} from "@t3tools/contracts/settings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; @@ -102,6 +101,7 @@ import { sortThreadsForSidebar, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -381,7 +381,8 @@ export default function Sidebar() { ); const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); - const { settings: appSettings, updateSettings } = useAppSettings(); + const appSettings = useSettings(); + const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 01a5d32d64..8770e58138 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -43,6 +43,70 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str document.body.append(host); const onPromptChange = vi.fn(); const providerOptions = props?.modelSelection?.options; + const models = + provider === "claudeAgent" + ? [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + ] + : [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ]; const screen = await render( >; +function effort(value: string, isDefault = false) { + return { + value, + label: value, + ...(isDefault ? { isDefault: true } : {}), + }; +} + +const TEST_PROVIDERS: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + authStatus: "authenticated", + checkedAt: new Date().toISOString(), + models: [ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ], + }, + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: new Date().toISOString(), + 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, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("medium", true), + effort("high"), + effort("max"), + ], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + ], + }, +]; async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; triggerVariant?: "ghost" | "outline"; }) { const host = document.createElement("div"); document.body.append(host); const onProviderModelChange = vi.fn(); + const providers = props.providers ?? TEST_PROVIDERS; + const modelOptionsByProvider = getCustomModelOptionsByProvider( + DEFAULT_UNIFIED_SETTINGS, + providers, + props.provider, + props.model, + ); const screen = await render( , @@ -159,6 +256,40 @@ describe("ProviderModelPicker", () => { } }); + it("shows disabled providers as non-selectable entries", async () => { + const disabledProviders = TEST_PROVIDERS.slice(); + const claudeIndex = disabledProviders.findIndex( + (provider) => provider.provider === "claudeAgent", + ); + if (claudeIndex >= 0) { + const claudeProvider = disabledProviders[claudeIndex]!; + disabledProviders[claudeIndex] = { + ...claudeProvider, + enabled: false, + status: "disabled", + }; + } + const mounted = await mountPicker({ + provider: "codex", + model: "gpt-5-codex", + lockedProvider: null, + providers: disabledProviders, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Claude"); + expect(text).toContain("Disabled"); + expect(text).not.toContain("Claude Sonnet 4.6"); + }); + } finally { + await mounted.cleanup(); + } + }); + it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ provider: "codex", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index ccf756fec6..5a09defc72 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -20,6 +20,7 @@ import { } from "../ui/menu"; import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; +import { getProviderSnapshot } from "../../providerModels"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { value: ProviderKind; @@ -53,6 +54,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; @@ -145,6 +147,31 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { <> {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 ( diff --git a/apps/web/src/components/chat/ProviderHealthBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx similarity index 77% rename from apps/web/src/components/chat/ProviderHealthBanner.tsx rename to apps/web/src/components/chat/ProviderStatusBanner.tsx index bfdefe58ec..e709e75da3 100644 --- a/apps/web/src/components/chat/ProviderHealthBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,14 +1,14 @@ -import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts"; +import { PROVIDER_DISPLAY_NAMES, type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { CircleAlertIcon } from "lucide-react"; -export const ProviderHealthBanner = memo(function ProviderHealthBanner({ +export const ProviderStatusBanner = memo(function ProviderStatusBanner({ status, }: { - status: ServerProviderStatus | null; + status: ServerProvider | null; }) { - if (!status || status.status === "ready") { + if (!status || status.status === "ready" || status.status === "disabled") { return null; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 61697c944a..bd8c61ee56 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -5,7 +5,9 @@ import { ClaudeModelOptions, CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_SERVER_SETTINGS, ProjectId, + type ServerProvider, ThreadId, } from "@t3tools/contracts"; import { page } from "vitest/browser"; @@ -21,10 +23,93 @@ import { useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; // ── Claude TraitsPicker tests ───────────────────────────────────────── const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); +const TEST_PROVIDERS: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ], + }, + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + ], + }, +]; function ClaudeTraitsPickerHarness(props: { model: string; @@ -35,10 +120,14 @@ function ClaudeTraitsPickerHarness(props: { const setPrompt = useComposerDraftStore((store) => store.setPrompt); const { modelOptions, selectedModel } = useEffectiveComposerModelState({ threadId: CLAUDE_THREAD_ID, + providers: TEST_PROVIDERS, selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [] }, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { @@ -50,6 +139,7 @@ function ClaudeTraitsPickerHarness(props: { return ( { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain("Low"); - expect(text).toContain("Medium"); - expect(text).toContain("High"); expect(text).toContain("Extra High"); + expect(text).toContain("High"); + expect(text).not.toContain("Low"); + expect(text).not.toContain("Medium"); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f48d525d02..5fd97b8cde 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -3,11 +3,11 @@ import { type CodexModelOptions, type ProviderKind, type ProviderModelOptions, + type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getModelCapabilities, isClaudeUltrathinkPrompt, trimOrNull, getDefaultEffort, @@ -27,6 +27,7 @@ import { MenuTrigger, } from "../ui/menu"; import { useComposerDraftStore } from "../../composerDraftStore"; +import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; type ProviderOptions = ProviderModelOptions[ProviderKind]; @@ -65,12 +66,13 @@ function buildNextOptions( function getSelectedTraits( provider: ProviderKind, + models: ReadonlyArray, model: string | null | undefined, prompt: string, modelOptions: ProviderOptions | null | undefined, allowPromptInjectedEffort: boolean, ) { - const caps = getModelCapabilities(provider, model); + const caps = getProviderModelCapabilities(models, model, provider); const effortLevels = allowPromptInjectedEffort ? caps.reasoningEffortLevels : caps.reasoningEffortLevels.filter( @@ -120,6 +122,7 @@ function getSelectedTraits( export interface TraitsMenuContentProps { provider: ProviderKind; + models: ReadonlyArray; model: string | null | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -131,6 +134,7 @@ export interface TraitsMenuContentProps { export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ provider, + models, model, prompt, onPromptChange, @@ -156,7 +160,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, - } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); + } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); const handleEffortChange = useCallback( @@ -260,6 +264,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ export const TraitsPicker = memo(function TraitsPicker({ provider, + models, model, prompt, onPromptChange, @@ -277,7 +282,7 @@ export const TraitsPicker = memo(function TraitsPicker({ thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, - } = getSelectedTraits(provider, model, prompt, modelOptions, allowPromptInjectedEffort); + } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) @@ -333,6 +338,7 @@ export const TraitsPicker = memo(function TraitsPicker({ = [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + +const CLAUDE_MODELS: ReadonlyArray = [ + { + slug: "claude-opus-4-6", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "max", label: "Max" }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + { + slug: "claude-haiku-4-5", + name: "Claude Haiku 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, +]; + describe("getComposerProviderState", () => { it("returns codex defaults when no codex draft options exist", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: undefined, }); @@ -21,6 +88,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: { codex: { @@ -44,6 +112,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: { codex: { @@ -65,6 +134,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", + models: CODEX_MODELS, prompt: "", modelOptions: { codex: { @@ -85,6 +155,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, prompt: "", modelOptions: undefined, }); @@ -100,6 +171,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, prompt: "Ultrathink:\nInvestigate this failure", modelOptions: { claudeAgent: { @@ -124,6 +196,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-haiku-4-5", + models: CLAUDE_MODELS, prompt: "", modelOptions: { claudeAgent: { @@ -146,6 +219,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", + models: CLAUDE_MODELS, prompt: "", modelOptions: { claudeAgent: { @@ -167,6 +241,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", + models: CLAUDE_MODELS, prompt: "", modelOptions: { claudeAgent: { diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 088a2a47be..2cebd8d4f4 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -2,23 +2,27 @@ import { type ModelSlug, type ProviderKind, type ProviderModelOptions, + type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; import { - getModelCapabilities, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, trimOrNull, getDefaultEffort, hasEffortLevel, } from "@t3tools/shared/model"; import type { ReactNode } from "react"; +import { + getProviderModelCapabilities, + normalizeClaudeModelOptionsWithCapabilities, + normalizeCodexModelOptionsWithCapabilities, +} from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; model: ModelSlug; + models: ReadonlyArray; prompt: string; modelOptions: ProviderModelOptions | null | undefined; }; @@ -37,6 +41,7 @@ type ProviderRegistryEntry = { renderTraitsMenuContent: (input: { threadId: ThreadId; model: ModelSlug; + models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -44,6 +49,7 @@ type ProviderRegistryEntry = { renderTraitsPicker: (input: { threadId: ThreadId; model: ModelSlug; + models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -53,8 +59,8 @@ type ProviderRegistryEntry = { function getProviderStateFromCapabilities( input: ComposerProviderStateInput, ): ComposerProviderState { - const { provider, model, prompt, modelOptions } = input; - const caps = getModelCapabilities(provider, model); + const { provider, model, models, prompt, modelOptions } = input; + const caps = getProviderModelCapabilities(models, model, provider); const providerOptions = modelOptions?.[provider]; // Resolve effort @@ -81,8 +87,8 @@ function getProviderStateFromCapabilities( // Normalize options for dispatch const normalizedOptions = provider === "codex" - ? normalizeCodexModelOptions(model, providerOptions) - : normalizeClaudeModelOptions(model, providerOptions); + ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) + : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = @@ -103,9 +109,17 @@ function getProviderStateFromCapabilities( const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( = { onPromptChange={onPromptChange} /> ), - renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( = { }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( = { onPromptChange={onPromptChange} /> ), - renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( ; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -164,6 +189,7 @@ export function renderProviderTraitsMenuContent(input: { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ threadId: input.threadId, model: input.model, + models: input.models, modelOptions: input.modelOptions, prompt: input.prompt, onPromptChange: input.onPromptChange, @@ -174,6 +200,7 @@ export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; onPromptChange: (prompt: string) => void; @@ -181,6 +208,7 @@ export function renderProviderTraitsPicker(input: { return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, + models: input.models, modelOptions: input.modelOptions, prompt: input.prompt, onPromptChange: input.onPromptChange, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a4acd810e7..3d54c526f1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -9,16 +9,13 @@ import { ProviderKind, ProviderModelOptions, RuntimeMode, + type ServerProvider, ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { - getDefaultModel, - normalizeModelSlug, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -31,6 +28,8 @@ import { import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; +import { getDefaultServerModel } from "./providerModels"; +import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 3; @@ -613,22 +612,23 @@ export function deriveEffectiveComposerModelState(input: { | Pick | null | undefined; + providers: ReadonlyArray; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; - customModelsByProvider: Record; + settings: UnifiedSettings; }): EffectiveComposerModelState { - const baseModel = resolveModelSlugForProvider( - input.selectedProvider, - input.threadModelSelection?.model ?? - input.projectModelSelection?.model ?? - getDefaultModel(input.selectedProvider), - ); + const baseModel = + normalizeModelSlug( + input.threadModelSelection?.model ?? input.projectModelSelection?.model, + input.selectedProvider, + ) ?? getDefaultServerModel(input.providers, input.selectedProvider); const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; const selectedModel = activeSelection?.model ? resolveAppModelSelection( input.selectedProvider, - input.customModelsByProvider, + input.settings, + input.providers, activeSelection.model, ) : baseModel; @@ -2157,10 +2157,11 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS export function useEffectiveComposerModelState(input: { threadId: ThreadId; + providers: ReadonlyArray; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; - customModelsByProvider: Record; + settings: UnifiedSettings; }): EffectiveComposerModelState { const draft = useComposerThreadDraft(input.threadId); @@ -2168,14 +2169,16 @@ export function useEffectiveComposerModelState(input: { () => deriveEffectiveComposerModelState({ draft, + providers: input.providers, selectedProvider: input.selectedProvider, threadModelSelection: input.threadModelSelection, projectModelSelection: input.projectModelSelection, - customModelsByProvider: input.customModelsByProvider, + settings: input.settings, }), [ draft, - input.customModelsByProvider, + input.providers, + input.settings, input.projectModelSelection, input.selectedProvider, input.threadModelSelection, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts new file mode 100644 index 0000000000..addf550e38 --- /dev/null +++ b/apps/web/src/hooks/useSettings.ts @@ -0,0 +1,266 @@ +/** + * Unified settings hook. + * + * Abstracts the split between server-authoritative settings (persisted in + * `settings.json` on the server, fetched via `server.getConfig`) and + * client-only settings (persisted in localStorage). + * + * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to + * write. The hook transparently routes reads/writes to the correct backing + * store. + */ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ServerSettings, + ServerSettingsPatch, + ServerConfig, + ModelSelection, + ThreadEnvMode, +} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { + type ClientSettings, + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + SidebarProjectSortOrder, + SidebarThreadSortOrder, + TimestampFormat, + UnifiedSettings, +} from "@t3tools/contracts/settings"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { useLocalStorage } from "./useLocalStorage"; +import { normalizeCustomModelSlugs } from "~/modelSelection"; +import { Predicate, Schema, Struct } from "effect"; +import { DeepMutable } from "effect/Types"; +import { deepMerge } from "@t3tools/shared/Struct"; + +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; + +// ── Key sets for routing patches ───────────────────────────────────── + +const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); + +function splitPatch(patch: Partial): { + serverPatch: ServerSettingsPatch; + clientPatch: Partial; +} { + const serverPatch: Record = {}; + const clientPatch: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (SERVER_SETTINGS_KEYS.has(key)) { + serverPatch[key] = value; + } else { + clientPatch[key] = value; + } + } + return { + serverPatch: serverPatch as ServerSettingsPatch, + clientPatch: clientPatch as Partial, + }; +} + +// ── Hooks ──────────────────────────────────────────────────────────── + +/** + * Read merged settings. Selector narrows the subscription so components + * only re-render when the slice they care about changes. + */ + +export function useSettings( + selector?: (s: UnifiedSettings) => T, +): T { + const { data: serverConfig } = useQuery(serverConfigQueryOptions()); + const [clientSettings] = useLocalStorage( + CLIENT_SETTINGS_STORAGE_KEY, + DEFAULT_CLIENT_SETTINGS, + ClientSettingsSchema, + ); + + const merged = useMemo( + () => ({ + ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS), + ...clientSettings, + }), + [serverConfig?.settings, clientSettings], + ); + + return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); +} + +/** + * Returns an updater that routes each key to the correct backing store. + * + * Server keys are optimistically patched in the React Query cache, then + * persisted via RPC. Client keys go straight to localStorage. + */ +export function useUpdateSettings() { + const queryClient = useQueryClient(); + const [, setClientSettings] = useLocalStorage( + CLIENT_SETTINGS_STORAGE_KEY, + DEFAULT_CLIENT_SETTINGS, + ClientSettingsSchema, + ); + + const updateSettings = useCallback( + (patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); + + if (Object.keys(serverPatch).length > 0) { + // Optimistic update of the React Query cache + queryClient.setQueryData(serverQueryKeys.config(), (old) => { + if (!old) return old; + return { + ...old, + settings: deepMerge(old.settings, serverPatch), + }; + }); + // Fire-and-forget RPC — push will reconcile on success + void ensureNativeApi().server.updateSettings(serverPatch); + } + + if (Object.keys(clientPatch).length > 0) { + setClientSettings((prev) => ({ ...prev, ...clientPatch })); + } + }, + [queryClient, setClientSettings], + ); + + const resetSettings = useCallback(() => { + updateSettings(DEFAULT_UNIFIED_SETTINGS); + }, [updateSettings]); + + return { + updateSettings, + resetSettings, + }; +} + +// ── One-time migration from localStorage ───────────────────────────── + +export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record) { + const patch: DeepMutable = {}; + + if (Predicate.isBoolean(legacySettings.enableAssistantStreaming)) { + patch.enableAssistantStreaming = legacySettings.enableAssistantStreaming; + } + + if (Schema.is(ThreadEnvMode)(legacySettings.defaultThreadEnvMode)) { + patch.defaultThreadEnvMode = legacySettings.defaultThreadEnvMode; + } + + if (Schema.is(ModelSelection)(legacySettings.textGenerationModelSelection)) { + patch.textGenerationModelSelection = legacySettings.textGenerationModelSelection; + } + + if (typeof legacySettings.codexBinaryPath === "string") { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.binaryPath = legacySettings.codexBinaryPath; + } + + if (typeof legacySettings.codexHomePath === "string") { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.homePath = legacySettings.codexHomePath; + } + + if (Array.isArray(legacySettings.customCodexModels)) { + patch.providers ??= {}; + patch.providers.codex ??= {}; + patch.providers.codex.customModels = normalizeCustomModelSlugs( + legacySettings.customCodexModels, + new Set(), + "codex", + ); + } + + if (Predicate.isString(legacySettings.claudeBinaryPath)) { + patch.providers ??= {}; + patch.providers.claudeAgent ??= {}; + patch.providers.claudeAgent.binaryPath = legacySettings.claudeBinaryPath; + } + + if (Array.isArray(legacySettings.customClaudeModels)) { + patch.providers ??= {}; + patch.providers.claudeAgent ??= {}; + patch.providers.claudeAgent.customModels = normalizeCustomModelSlugs( + legacySettings.customClaudeModels, + new Set(), + "claudeAgent", + ); + } + + return patch; +} + +export function buildLegacyClientSettingsMigrationPatch( + legacySettings: Record, +): Partial> { + const patch: Partial> = {}; + + if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { + patch.confirmThreadDelete = legacySettings.confirmThreadDelete; + } + + if (Predicate.isBoolean(legacySettings.diffWordWrap)) { + patch.diffWordWrap = legacySettings.diffWordWrap; + } + + if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { + patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; + } + + if (Schema.is(SidebarThreadSortOrder)(legacySettings.sidebarThreadSortOrder)) { + patch.sidebarThreadSortOrder = legacySettings.sidebarThreadSortOrder; + } + + if (Schema.is(TimestampFormat)(legacySettings.timestampFormat)) { + patch.timestampFormat = legacySettings.timestampFormat; + } + + return patch; +} + +/** + * Call once on app startup. + * If the legacy localStorage key exists, migrate its values to the new server + * and client storage formats, then remove the legacy key so this only runs once. + */ +export function migrateLocalSettingsToServer(): void { + if (typeof window === "undefined") return; + + const raw = localStorage.getItem(OLD_SETTINGS_KEY); + if (!raw) return; + + try { + const old = JSON.parse(raw); + if (!Predicate.isObject(old)) return; + + // Migrate server-relevant keys via RPC + const serverPatch = buildLegacyServerSettingsMigrationPatch(old); + if (Object.keys(serverPatch).length > 0) { + const api = ensureNativeApi(); + void api.server.updateSettings(serverPatch); + } + + // Migrate client-only keys to the new localStorage key + const clientPatch = buildLegacyClientSettingsMigrationPatch(old); + if (Object.keys(clientPatch).length > 0) { + const existing = localStorage.getItem(CLIENT_SETTINGS_STORAGE_KEY); + const current = existing ? (JSON.parse(existing) as Record) : {}; + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ ...current, ...clientPatch }), + ); + } + } catch (error) { + console.error("[MIGRATION] Error migrating local settings:", error); + } finally { + // Remove the legacy key regardless to keep migration one-shot behavior. + localStorage.removeItem(OLD_SETTINGS_KEY); + } +} diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index b5d75f743c..25fcba7843 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -32,10 +32,6 @@ describe("git mutation options", () => { const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient, - modelSelection: { - provider: "codex", - model: "gpt-5.4", - }, }); expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 02d725d2b2..cfa2c72f74 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,4 @@ -import { type GitStackedAction, type ModelSelection } from "@t3tools/contracts"; +import { type GitStackedAction } from "@t3tools/contracts"; import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; @@ -112,7 +112,6 @@ export function gitCheckoutMutationOptions(input: { export function gitRunStackedActionMutationOptions(input: { cwd: string | null; queryClient: QueryClient; - modelSelection: ModelSelection; }) { return mutationOptions({ mutationKey: gitMutationKeys.runStackedAction(input.cwd), @@ -134,7 +133,6 @@ export function gitRunStackedActionMutationOptions(input: { return api.git.runStackedAction({ actionId, cwd: input.cwd, - modelSelection: input.modelSelection, action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 85853e2ee2..37029a3a3e 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -6,6 +6,13 @@ export const serverQueryKeys = { config: () => ["server", "config"] as const, }; +/** + * Server config query options. + * + * `staleTime` is kept short so that push-driven `invalidateQueries` calls in + * the EventRouter always trigger a refetch, and so the query re-fetches when + * the component re-mounts (e.g. navigating away from settings and back). + */ export function serverConfigQueryOptions() { return queryOptions({ queryKey: serverQueryKeys.config(), @@ -13,6 +20,5 @@ export function serverConfigQueryOptions() { const api = ensureNativeApi(); return api.server.getConfig(); }, - staleTime: Infinity, }); } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 9534170b1f..98e2884adf 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -2,27 +2,22 @@ import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, type ModelSelection, type ProviderKind, + type ServerProvider, } from "@t3tools/contracts"; -import { - getDefaultModel, - getModelOptions, - normalizeModelSlug, - resolveSelectableModel, -} from "@t3tools/shared/model"; +import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; +import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { + getDefaultServerModel, + getProviderModels, + resolveSelectableProvider, +} from "./providerModels"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export type CustomModelSettings = { - customCodexModels: readonly string[]; - customClaudeModels: readonly string[]; -}; - export type ProviderCustomModelConfig = { provider: ProviderKind; - settingsKey: keyof CustomModelSettings; - defaultSettingsKey: keyof CustomModelSettings; title: string; description: string; placeholder: string; @@ -38,8 +33,6 @@ export interface AppModelOption { const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", - settingsKey: "customCodexModels", - defaultSettingsKey: "customCodexModels", title: "Codex", description: "Save additional Codex model slugs for the picker and `/model` command.", placeholder: "your-codex-model-slug", @@ -47,8 +40,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record> = { - codex: new Set(getModelOptions("codex").map((option) => option.slug)), - claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), -}; - export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable, + builtInModelSlugs: ReadonlySet, provider: ProviderKind = "codex", ): string[] { const normalizedModels: string[] = []; const seen = new Set(); - const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { const normalized = normalizeModelSlug(candidate, provider); @@ -92,52 +78,29 @@ export function normalizeCustomModelSlugs( return normalizedModels; } -export function getCustomModelsForProvider( - settings: CustomModelSettings, - provider: ProviderKind, -): readonly string[] { - return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; -} - -export function getDefaultCustomModelsForProvider( - defaults: CustomModelSettings, - provider: ProviderKind, -): readonly string[] { - return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; -} - -export function patchCustomModels( - provider: ProviderKind, - models: string[], -): Partial { - return { - [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, - }; -} - -export function getCustomModelsByProvider( - settings: CustomModelSettings, -): Record { - return { - codex: getCustomModelsForProvider(settings, "codex"), - claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), - }; -} - export function getAppModelOptions( + settings: UnifiedSettings, + providers: ReadonlyArray, provider: ProviderKind, - customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ - slug, - name, - isCustom: false, - })); + const options: AppModelOption[] = getProviderModels(providers, provider).map( + ({ slug, name, isCustom }) => ({ + slug, + name, + isCustom, + }), + ); const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); - - for (const slug of normalizeCustomModelSlugs(customModels, provider)) { + const builtInModelSlugs = new Set( + getProviderModels(providers, provider) + .filter((model) => !model.isCustom) + .map((model) => model.slug), + ); + + const customModels = settings.providers[provider].customModels; + for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs, provider)) { if (seen.has(slug)) { continue; } @@ -171,52 +134,61 @@ export function getAppModelOptions( export function resolveAppModelSelection( provider: ProviderKind, - customModels: Record, + settings: UnifiedSettings, + providers: ReadonlyArray, selectedModel: string | null | undefined, ): string { - const customModelsForProvider = customModels[provider]; - const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); - return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); + const resolvedProvider = resolveSelectableProvider(providers, provider); + const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); + return ( + resolveSelectableModel(resolvedProvider, selectedModel, options) ?? + getDefaultServerModel(providers, resolvedProvider) + ); } export function getCustomModelOptionsByProvider( - settings: CustomModelSettings, + settings: UnifiedSettings, + providers: ReadonlyArray, selectedProvider?: ProviderKind | null, selectedModel?: string | null, ): Record> { - const customModelsByProvider = getCustomModelsByProvider(settings); return { codex: getAppModelOptions( + settings, + providers, "codex", - customModelsByProvider.codex, selectedProvider === "codex" ? selectedModel : undefined, ), claudeAgent: getAppModelOptions( + settings, + providers, "claudeAgent", - customModelsByProvider.claudeAgent, selectedProvider === "claudeAgent" ? selectedModel : undefined, ), }; } export function resolveAppModelSelectionState( - settings: CustomModelSettings & { - textGenerationModelSelection: ModelSelection | undefined; - }, + settings: UnifiedSettings, + providers: ReadonlyArray, ): ModelSelection { const selection = settings.textGenerationModelSelection ?? { provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = selection.provider; - const customModelsByProvider = getCustomModelsByProvider(settings); - const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model); + const provider = resolveSelectableProvider(providers, selection.provider); + + // When the provider changed due to fallback (e.g. selected provider was disabled), + // don't carry over the old provider's model — use the fallback provider's default. + const selectedModel = provider === selection.provider ? selection.model : null; + const model = resolveAppModelSelection(provider, settings, providers, selectedModel); const { modelOptionsForDispatch } = getComposerProviderState({ provider, model, + models: getProviderModels(providers, provider), prompt: "", modelOptions: { - [provider]: selection.options, + [provider]: provider === selection.provider ? selection.options : undefined, }, }); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts new file mode 100644 index 0000000000..a925ed690f --- /dev/null +++ b/apps/web/src/providerModels.ts @@ -0,0 +1,116 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + type ClaudeModelOptions, + type CodexModelOptions, + type ModelCapabilities, + type ProviderKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { + getDefaultEffort, + hasEffortLevel, + normalizeModelSlug, + trimOrNull, +} from "@t3tools/shared/model"; + +const EMPTY_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], +}; + +export function getProviderModels( + providers: ReadonlyArray, + provider: ProviderKind, +): ReadonlyArray { + return providers.find((candidate) => candidate.provider === provider)?.models ?? []; +} + +export function getProviderSnapshot( + providers: ReadonlyArray, + provider: ProviderKind, +): ServerProvider | undefined { + return providers.find((candidate) => candidate.provider === provider); +} + +export function isProviderEnabled( + providers: ReadonlyArray, + provider: ProviderKind, +): boolean { + return getProviderSnapshot(providers, provider)?.enabled ?? true; +} + +export function resolveSelectableProvider( + providers: ReadonlyArray, + provider: ProviderKind | null | undefined, +): ProviderKind { + const requested = provider ?? "codex"; + if (isProviderEnabled(providers, requested)) { + return requested; + } + return providers.find((candidate) => candidate.enabled)?.provider ?? requested; +} + +export function getProviderModelCapabilities( + models: ReadonlyArray, + model: string | null | undefined, + provider: ProviderKind, +): ModelCapabilities { + const slug = normalizeModelSlug(model, provider); + return models.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES; +} + +export function getDefaultServerModel( + providers: ReadonlyArray, + provider: ProviderKind, +): string { + const models = getProviderModels(providers, provider); + return ( + models.find((model) => !model.isCustom)?.slug ?? + models[0]?.slug ?? + DEFAULT_MODEL_BY_PROVIDER[provider] + ); +} + +export function normalizeCodexModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..5ebed20fba 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -20,7 +20,8 @@ import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDra import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; -import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; +import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; +import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; @@ -230,6 +231,8 @@ function EventRouter() { ); }); const unsubWelcome = onServerWelcome((payload) => { + // Migrate old localStorage settings to server on first connect + migrateLocalSettingsToServer(); void (async () => { await syncSnapshot(); if (disposed) { @@ -260,8 +263,14 @@ function EventRouter() { // don't produce duplicate toasts. let subscribed = false; const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { + // Invalidate the config query so active observers refetch fresh data. void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + if (!subscribed) return; + + // Only show keybindings toasts for keybindings changes (no settings in payload) + if (payload.settings) return; + const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { toastManager.add({ @@ -300,6 +309,9 @@ function EventRouter() { }, }); }); + const unsubProvidersUpdated = onServerProvidersUpdated(() => { + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + }); subscribed = true; return () => { disposed = true; @@ -309,6 +321,7 @@ function EventRouter() { unsubTerminalEvent(); unsubWelcome(); unsubServerConfigUpdated(); + unsubProvidersUpdated(); }; }, [ navigate, diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 62a27edba9..3e92891a54 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,16 +1,27 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; -import { type ReactNode, useCallback, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; -import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { useAppSettings } from "../appSettings"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ChevronDownIcon, + InfoIcon, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + RotateCcwIcon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { + PROVIDER_DISPLAY_NAMES, + type ProviderKind, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { useSettings, useUpdateSettings } from "../hooks/useSettings"; import { getCustomModelOptionsByProvider, - getCustomModelsForProvider, MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - patchCustomModels, resolveAppModelSelectionState, } from "../modelSelection"; import { APP_VERSION } from "../branding"; @@ -33,9 +44,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; +import { formatRelativeTime } from "../timestampFormat"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { Equal } from "effect"; const THEME_OPTIONS = [ { @@ -61,11 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; + type InstallProviderSettings = { provider: ProviderKind; title: string; - binaryPathKey: InstallBinarySettingsKey; binaryPlaceholder: string; binaryDescription: ReactNode; homePathKey?: "codexHomePath"; @@ -73,17 +87,12 @@ type InstallProviderSettings = { homeDescription?: ReactNode; }; -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ +const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "codex", title: "Codex", - binaryPathKey: "codexBinaryPath", binaryPlaceholder: "Codex binary path", - binaryDescription: ( - <> - Leave blank to use codex from your PATH. - - ), + binaryDescription: "Path to the Codex binary", homePathKey: "codexHomePath", homePlaceholder: "CODEX_HOME", homeDescription: "Optional custom Codex home and config directory.", @@ -91,22 +100,116 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "claudeAgent", title: "Claude", - binaryPathKey: "claudeBinaryPath", binaryPlaceholder: "Claude binary path", - binaryDescription: ( - <> - Leave blank to use claude from your PATH. - - ), + binaryDescription: "Path to the Claude binary", }, ]; -function SettingsSection({ title, children }: { title: string; children: ReactNode }) { +const PROVIDER_STATUS_STYLES = { + disabled: { + dot: "bg-amber-400", + badge: "warning" as const, + }, + error: { + dot: "bg-destructive", + badge: "error" as const, + }, + ready: { + dot: "bg-success", + badge: "success" as const, + }, + warning: { + dot: "bg-warning", + badge: "warning" as const, + }, +} as const; + +function getProviderSummary(provider: ServerProvider | undefined): { + readonly headline: string; + readonly detail: string | null; +} { + if (!provider) { + return { + headline: "Checking provider status", + detail: "Waiting for the server to report installation and authentication details.", + }; + } + if (!provider.enabled) { + return { + headline: "Disabled", + detail: + provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", + }; + } + if (!provider.installed) { + return { + headline: "Not found", + detail: provider.message ?? "CLI not detected on PATH.", + }; + } + if (provider.authStatus === "authenticated") { + return { + headline: "Authenticated", + detail: provider.message ?? null, + }; + } + if (provider.authStatus === "unauthenticated") { + return { + headline: "Not authenticated", + detail: provider.message ?? null, + }; + } + if (provider.status === "warning") { + return { + headline: "Needs attention", + detail: + provider.message ?? "The provider is installed, but the server could not fully verify it.", + }; + } + if (provider.status === "error") { + return { + headline: "Unavailable", + detail: provider.message ?? "The provider failed its startup checks.", + }; + } + return { + headline: "Available", + detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + }; +} + +function getProviderVersionLabel(version: string | null | undefined): string | null { + if (!version) return null; + return version.startsWith("v") ? version : `v${version}`; +} + +/** Returns a timestamp that updates on an interval, forcing re-renders to keep relative times fresh. */ +function useRelativeTimeTick(intervalMs = 1_000): number { + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return tick; +} + +function SettingsSection({ + title, + headerAction, + children, +}: { + title: string; + headerAction?: ReactNode; + children: ReactNode; +}) { return (
-

- {title} -

+
+

+ {title} +

+ {headerAction} +
{children}
@@ -121,7 +224,6 @@ function SettingsRow({ resetAction, control, children, - onClick, }: { title: string; description: string; @@ -129,20 +231,13 @@ function SettingsRow({ resetAction?: ReactNode; control?: ReactNode; children?: ReactNode; - onClick?: () => void; }) { return (
-
+

{title}

@@ -190,16 +285,23 @@ function SettingResetButton({ label, onClick }: { label: string; onClick: () => function SettingsRouteView() { const { theme, setTheme } = useTheme(); - const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); + const settings = useSettings(); + const { updateSettings, resetSettings } = useUpdateSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), + const [openProviderDetails, setOpenProviderDetails] = useState>({ + codex: Boolean( + settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || + settings.providers.codex.customModels.length > 0, + ), + claudeAgent: Boolean( + settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || + settings.providers.claudeAgent.customModels.length > 0, + ), }); - const [selectedCustomModelProvider, setSelectedCustomModelProvider] = - useState("codex"); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -209,63 +311,73 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); - const [showAllCustomModels, setShowAllCustomModels] = useState(false); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const refreshingRef = useRef(false); + const queryClient = useQueryClient(); + useRelativeTimeTick(); + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + const api = ensureNativeApi(); + api.server + .refreshProviders() + .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) + .catch((error: unknown) => { + console.warn("Failed to refresh providers", error); + }) + .finally(() => { + refreshingRef.current = false; + setIsRefreshingProviders(false); + }); + }, [queryClient]); - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const claudeBinaryPath = settings.claudeBinaryPath; + const modelListRefs = useRef>>({}); + + const codexHomePath = settings.providers.codex.homePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; - const textGenerationModelSelection = resolveAppModelSelectionState(settings); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const gitModelOptionsByProvider = getCustomModelOptionsByProvider( settings, + serverProviders, textGenProvider, textGenModel, ); - const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( - (providerSettings) => providerSettings.provider === selectedCustomModelProvider, - )!; - const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; - const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; - const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => - getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ - key: `${providerSettings.provider}:${slug}`, - provider: providerSettings.provider, - providerTitle: providerSettings.title, - slug, - })), + const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { + const currentSettings = settings.providers[providerSettings.provider]; + const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + return !Equal.equals(currentSettings, defaultSettings); + }); + const isGitWritingModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); - const visibleCustomModelRows = showAllCustomModels - ? savedCustomModelRows - : savedCustomModelRows.slice(0, 5); - const isInstallSettingsDirty = - settings.claudeBinaryPath !== defaults.claudeBinaryPath || - settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath; const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), - ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), - ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []), - ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming - ? ["Assistant output"] + ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat + ? ["Time format"] : []), - ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete - ? ["Delete confirmation"] + ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap + ? ["Diff line wrapping"] + : []), + ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming + ? ["Assistant output"] : []), - ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !== - JSON.stringify(defaults.textGenerationModelSelection ?? null) - ? ["Git writing model"] + ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode + ? ["New thread mode"] : []), - ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 - ? ["Custom models"] + ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete + ? ["Delete confirmation"] : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), + ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(areProviderSettingsDirty ? ["Providers"] : []), ]; const openKeybindingsFile = useCallback(() => { @@ -294,7 +406,7 @@ function SettingsRouteView() { const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; - const customModels = getCustomModelsForProvider(settings, provider); + const customModels = settings.providers[provider].customModels; const normalized = normalizeModelSlug(customModelInput, provider); if (!normalized) { setCustomModelErrorByProvider((existing) => ({ @@ -303,7 +415,11 @@ function SettingsRouteView() { })); return; } - if (getModelOptions(provider).some((option) => option.slug === normalized)) { + if ( + serverProviders + .find((candidate) => candidate.provider === provider) + ?.models.some((option) => !option.isCustom && option.slug === normalized) + ) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: "That model is already built in.", @@ -325,7 +441,15 @@ function SettingsRouteView() { return; } - updateSettings(patchCustomModels(provider, [...customModels, normalized])); + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: [...customModels, normalized], + }, + }, + }); setCustomModelInputByProvider((existing) => ({ ...existing, [provider]: "", @@ -334,19 +458,37 @@ function SettingsRouteView() { ...existing, [provider]: null, })); + // Watch for DOM changes (server may push updated model list) and scroll to bottom + const el = modelListRefs.current[provider]; + if (el) { + const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + // Immediate scroll for the optimistic update + requestAnimationFrame(scrollToEnd); + // Also observe mutations for when the server pushes an updated list + const observer = new MutationObserver(() => { + scrollToEnd(); + observer.disconnect(); + }); + observer.observe(el, { childList: true, subtree: true }); + // Clean up observer after a reasonable window + setTimeout(() => observer.disconnect(), 2000); + } }, - [customModelInputByProvider, settings, updateSettings], + [customModelInputByProvider, serverProviders, settings, updateSettings], ); const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { - const customModels = getCustomModelsForProvider(settings, provider); - updateSettings( - patchCustomModels( - provider, - customModels.filter((model) => model !== slug), - ), - ); + const customModels = settings.providers[provider].customModels; + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: customModels.filter((model) => model !== slug), + }, + }, + }); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, @@ -355,6 +497,46 @@ function SettingsRouteView() { [settings, updateSettings], ); + const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { + const liveProvider = serverProviders.find( + (candidate) => candidate.provider === providerSettings.provider, + ); + const providerConfig = settings.providers[providerSettings.provider]; + const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); + const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; + const summary = getProviderSummary(liveProvider); + const models: ReadonlyArray = + liveProvider?.models ?? + providerConfig.customModels.map((slug) => ({ + slug, + name: slug, + isCustom: true, + capabilities: null, + })); + const binaryPathValue = providerConfig.binaryPath; + const isDirty = !Equal.equals(providerConfig, defaultProviderConfig); + + return { + provider: providerSettings.provider, + title: providerSettings.title, + binaryPlaceholder: providerSettings.binaryPlaceholder, + binaryDescription: providerSettings.binaryDescription, + homePathKey: providerSettings.homePathKey, + homePlaceholder: providerSettings.homePlaceholder, + homeDescription: providerSettings.homeDescription, + binaryPathValue, + isDirty, + liveProvider, + models, + providerConfig, + statusKey, + statusStyle, + summary, + versionLabel: getProviderVersionLabel(liveProvider?.version), + }; + }); + async function restoreDefaults() { if (changedSettingLabels.length === 0) return; @@ -368,11 +550,10 @@ function SettingsRouteView() { setTheme("system"); resetSettings(); - setOpenInstallProviders({ + setOpenProviderDetails({ codex: false, claudeAgent: false, }); - setSelectedCustomModelProvider("codex"); setCustomModelInputByProvider({ codex: "", claudeAgent: "", @@ -461,12 +642,12 @@ function SettingsRouteView() { title="Time format" description="System default follows your browser or OS clock preference." resetAction={ - settings.timestampFormat !== defaults.timestampFormat ? ( + settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ( updateSettings({ - timestampFormat: defaults.timestampFormat, + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, }) } /> @@ -506,12 +687,12 @@ function SettingsRouteView() { title="Diff line wrapping" description="Set the default wrap state when the diff panel opens. The in-panel wrap toggle only affects the current diff session." resetAction={ - settings.diffWordWrap !== defaults.diffWordWrap ? ( + settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ( updateSettings({ - diffWordWrap: defaults.diffWordWrap, + diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, }) } /> @@ -534,12 +715,14 @@ function SettingsRouteView() { title="Assistant output" description="Show token-by-token output while a response is in progress." resetAction={ - settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( + settings.enableAssistantStreaming !== + DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ( updateSettings({ - enableAssistantStreaming: defaults.enableAssistantStreaming, + enableAssistantStreaming: + DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, }) } /> @@ -562,12 +745,13 @@ function SettingsRouteView() { title="New threads" description="Pick the default workspace mode for newly created draft threads." resetAction={ - settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( + settings.defaultThreadEnvMode !== + DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ( updateSettings({ - defaultThreadEnvMode: defaults.defaultThreadEnvMode, + defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, }) } /> @@ -604,12 +788,12 @@ function SettingsRouteView() { title="Delete confirmation" description="Ask before deleting a thread and its chat history." resetAction={ - settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( + settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ( updateSettings({ - confirmThreadDelete: defaults.confirmThreadDelete, + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, }) } /> @@ -627,20 +811,18 @@ function SettingsRouteView() { /> } /> - - - { updateSettings({ - textGenerationModelSelection: defaults.textGenerationModelSelection, + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); }} /> @@ -652,20 +834,28 @@ function SettingsRouteView() { provider={textGenProvider} model={textGenModel} lockedProvider={null} + providers={serverProviders} modelOptionsByProvider={gitModelOptionsByProvider} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onProviderModelChange={(provider, model) => { updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState({ - ...settings, - textGenerationModelSelection: { provider, model }, - }), + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { provider, model }, + }, + serverProviders, + ), }); }} /> provider.provider === textGenProvider) + ?.models ?? [] + } model={textGenModel} prompt="" onPromptChange={() => {}} @@ -675,292 +865,407 @@ function SettingsRouteView() { triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState({ - ...settings, - textGenerationModelSelection: { - provider: textGenProvider, - model: textGenModel, - ...(nextOptions ? { options: nextOptions } : {}), + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { + provider: textGenProvider, + model: textGenModel, + ...(nextOptions ? { options: nextOptions } : {}), + }, }, - }), + serverProviders, + ), }); }} />
} /> + - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } - > -
-
- - { - const value = event.target.value; - setCustomModelInputByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: value, - })); - if (selectedCustomModelError) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [selectedCustomModelProvider]: null, - })); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addCustomModel(selectedCustomModelProvider); - }} - placeholder={selectedCustomModelProviderSettings.example} - spellCheck={false} - /> - -
- - {selectedCustomModelError ? ( -

{selectedCustomModelError}

+ + {serverProviders.length > 0 ? ( + + {(() => { + const rel = formatRelativeTime( + serverProviders.reduce( + (latest, provider) => + provider.checkedAt > latest ? provider.checkedAt : latest, + serverProviders[0]!.checkedAt, + ), + ); + return rel.suffix ? ( + <> + Checked {rel.value}{" "} + {rel.suffix} + + ) : ( + <>Checked {rel.value} + ); + })()} + ) : null} + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + +
+ } + > + {providerCards.map((providerCard) => { + const customModelInput = customModelInputByProvider[providerCard.provider]; + const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; + const providerDisplayName = + PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} + return ( +
+
+
+
+
+ +

+ {providerDisplayName} +

+ {providerCard.versionLabel ? ( + + {providerCard.versionLabel} + + ) : null} + + {providerCard.isDirty ? ( + { + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: + DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], + }, + }); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + }} + /> + ) : null} - - {row.slug} - -
- ))} +

+ {providerCard.summary.headline} + {providerCard.summary.detail + ? ` — ${providerCard.summary.detail}` + : null} +

+
+
+ + { + const isDisabling = !checked; + // The resolved provider accounts for both explicit + // selection and the implicit default (codex). + const resolvedProvider = textGenProvider; + // When disabling the provider that's currently used for + // text generation, clear the selection so it falls back to + // the next available provider's default model. + const shouldClearModelSelection = + isDisabling && resolvedProvider === providerCard.provider; + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + enabled: Boolean(checked), + }, + }, + ...(shouldClearModelSelection + ? { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + } + : {}), + }); + }} + aria-label={`Enable ${providerDisplayName}`} + /> +
- - {savedCustomModelRows.length > 5 ? ( - - ) : null}
- ) : null} -
- - - - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - }); - }} - /> - ) : null - } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- +
+ + {/* Home path (Codex only) */} + {providerCard.homePathKey ? ( +
+ +
+ ) : null} + + {/* Models */} +
+
Models
+
+ {providerCard.models.length} model + {providerCard.models.length === 1 ? "" : "s"} available. +
+
{ + modelListRefs.current[providerCard.provider] = el; + }} + className="mt-2 max-h-40 overflow-y-auto pb-1" + > + {providerCard.models.map((model) => { + const caps = model.capabilities; + const capLabels: string[] = []; + if (caps?.supportsFastMode) capLabels.push("Fast mode"); + if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); + if ( + caps?.reasoningEffortLevels && + caps.reasoningEffortLevels.length > 0 + ) + capLabels.push("Reasoning"); + const hasDetails = + capLabels.length > 0 || model.name !== model.slug; + + return ( +
- - {providerSettings.title} binary path + + {model.name} - - updateSettings( - providerSettings.binaryPathKey === "claudeBinaryPath" - ? { claudeBinaryPath: event.target.value } - : { codexBinaryPath: event.target.value }, - ) - } - placeholder={providerSettings.binaryPlaceholder} - spellCheck={false} - /> - - {providerSettings.binaryDescription} - - - - {providerSettings.homePathKey ? ( -
- + +
+ ) : null} +
+ ); + })} +
+
+ { + const value = event.target.value; + setCustomModelInputByProvider((existing) => ({ + ...existing, + [providerCard.provider]: value, + })); + if (customModelError) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(providerCard.provider); + }} + placeholder={ + providerCard.provider === "codex" + ? "gpt-6.7-codex-ultra-preview" + : "claude-sonnet-5-0" + } + spellCheck={false} + /> + +
+ {customModelError ? ( +

{customModelError}

+ ) : null}
-
- ); - })} +
+ +
-
- + ); + })} + + { const onWindowKeyDown = (event: KeyboardEvent) => { diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index d4ffa3c376..a75ab21019 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,4 +1,4 @@ -import { type TimestampFormat } from "./appSettings"; +import { type TimestampFormat } from "@t3tools/contracts/settings"; export function getTimestampFormatOptions( timestampFormat: TimestampFormat, @@ -47,3 +47,22 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); } + +/** + * Format a relative time string from an ISO date. + * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` + * so callers can style the numeric portion independently. + */ +export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { + const diffMs = Date.now() - new Date(isoDate).getTime(); + if (diffMs < 0) return { value: "just now", suffix: null }; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 5) return { value: "just now", suffix: null }; + if (seconds < 60) return { value: `${seconds}s`, suffix: "ago" }; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return { value: `${minutes}m`, suffix: "ago" }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { value: `${hours}h`, suffix: "ago" }; + const days = Math.floor(hours / 24); + return { value: `${days}d`, suffix: "ago" }; +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 86ac4e9ba6..9e612543d0 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -13,7 +13,7 @@ import { WS_CHANNELS, WS_METHODS, type WsPush, - type ServerProviderStatus, + type ServerProvider, } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -92,13 +92,16 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn return testGlobal.window; } -const defaultProviders: ReadonlyArray = [ +const defaultProviders: ReadonlyArray = [ { provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", status: "ready", - available: true, authStatus: "authenticated", checkedAt: "2026-01-01T00:00:00.000Z", + models: [], }, ]; @@ -197,7 +200,6 @@ describe("wsNativeApi", () => { message: "Entry at index 1 is invalid.", }, ], - providers: defaultProviders, } as const; emitPush(WS_CHANNELS.serverConfigUpdated, payload); @@ -219,20 +221,38 @@ describe("wsNativeApi", () => { emitPush(WS_CHANNELS.serverConfigUpdated, { issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: defaultProviders, }); emitPush(WS_CHANNELS.serverConfigUpdated, { issues: [], - providers: defaultProviders, }); expect(listener).toHaveBeenCalledTimes(2); expect(listener).toHaveBeenLastCalledWith({ issues: [], - providers: defaultProviders, }); }); + it("delivers and caches valid server.providersUpdated payloads", async () => { + const { createWsNativeApi, onServerProvidersUpdated } = await import("./wsNativeApi"); + + createWsNativeApi(); + const listener = vi.fn(); + onServerProvidersUpdated(listener); + + const payload = { + providers: defaultProviders, + } as const; + emitPush(WS_CHANNELS.serverProvidersUpdated, payload); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(payload); + + const lateListener = vi.fn(); + onServerProvidersUpdated(lateListener); + expect(lateListener).toHaveBeenCalledTimes(1); + expect(lateListener).toHaveBeenCalledWith(payload); + }); + it("forwards valid terminal and orchestration events", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); @@ -357,10 +377,6 @@ describe("wsNativeApi", () => { actionId: "action-1", cwd: "/repo", action: "commit", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }); expect(requestMock).toHaveBeenCalledWith( @@ -369,10 +385,6 @@ describe("wsNativeApi", () => { actionId: "action-1", cwd: "/repo", action: "commit", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }, { timeoutMs: null }, ); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 042875f6f7..7024ffdb47 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -5,6 +5,7 @@ import { type ContextMenuItem, type NativeApi, ServerConfigUpdatedPayload, + ServerProviderUpdatedPayload, WS_CHANNELS, WS_METHODS, type WsWelcomePayload, @@ -16,6 +17,7 @@ import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); const serverConfigUpdatedListeners = new Set<(payload: ServerConfigUpdatedPayload) => void>(); +const providersUpdatedListeners = new Set<(payload: ServerProviderUpdatedPayload) => void>(); const gitActionProgressListeners = new Set<(payload: GitActionProgressEvent) => void>(); /** @@ -64,6 +66,26 @@ export function onServerConfigUpdated( }; } +export function onServerProvidersUpdated( + listener: (payload: ServerProviderUpdatedPayload) => void, +): () => void { + providersUpdatedListeners.add(listener); + + const latestProviders = + instance?.transport.getLatestPush(WS_CHANNELS.serverProvidersUpdated)?.data ?? null; + if (latestProviders) { + try { + listener(latestProviders); + } catch { + // Swallow listener errors + } + } + + return () => { + providersUpdatedListeners.delete(listener); + }; +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; @@ -89,6 +111,16 @@ export function createWsNativeApi(): NativeApi { } } }); + transport.subscribe(WS_CHANNELS.serverProvidersUpdated, (message) => { + const payload = message.data; + for (const listener of providersUpdatedListeners) { + try { + listener(payload); + } catch { + // Swallow listener errors + } + } + }); transport.subscribe(WS_CHANNELS.gitActionProgress, (message) => { const payload = message.data; for (const listener of gitActionProgressListeners) { @@ -178,7 +210,10 @@ export function createWsNativeApi(): NativeApi { }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), + refreshProviders: () => transport.request(WS_METHODS.serverRefreshProviders), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + getSettings: () => transport.request(WS_METHODS.serverGetSettings), + updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index 55ff2556e4..e66ed7fc0d 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -98,7 +98,7 @@ describe("WsTransport", () => { type: "push", sequence: 1, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }), ); @@ -107,7 +107,7 @@ describe("WsTransport", () => { type: "push", sequence: 1, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }); transport.dispose(); @@ -160,7 +160,7 @@ describe("WsTransport", () => { type: "push", sequence: 3, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }), ); @@ -169,7 +169,7 @@ describe("WsTransport", () => { type: "push", sequence: 3, channel: WS_CHANNELS.serverConfigUpdated, - data: { issues: [], providers: [] }, + data: { issues: [] }, }); expect(warnSpy).toHaveBeenCalledTimes(2); expect(warnSpy).toHaveBeenNthCalledWith( diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 35f8b15498..29d0c1398f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,11 @@ "module": "./dist/index.mjs", "types": "./src/index.ts", "exports": { + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts", + "require": "./src/settings.ts" + }, ".": { "types": "./src/index.ts", "import": "./src/index.ts", diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index e9446b540a..d2bfac6028 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -74,19 +74,4 @@ describe("GitRunStackedActionInput", () => { expect(parsed.actionId).toBe("action-1"); expect(parsed.action).toBe("commit"); }); - - it("accepts git text generation as a modelSelection", () => { - const parsed = decodeRunStackedActionInput({ - actionId: "action-1", - cwd: "/repo", - action: "commit_push_pr", - modelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - }, - }); - - expect(parsed.modelSelection?.provider).toBe("claudeAgent"); - expect(parsed.modelSelection?.model).toBe("claude-haiku-4-5"); - }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 93264626cd..f8b65abf2c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,5 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; -import { ModelSelection } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -80,7 +79,6 @@ export const GitRunStackedActionInput = Schema.Struct({ filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), - modelSelection: ModelSelection, }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..248b3a04f9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -7,6 +7,7 @@ export * from "./model"; export * from "./ws"; export * from "./keybindings"; export * from "./server"; +export * from "./settings"; export * from "./git"; export * from "./orchestration"; export * from "./editor"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea73024de3..1d282ae721 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,7 +25,11 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { ServerConfig } from "./server"; +import type { + ServerConfig, + ServerProviderUpdatedPayload, + ServerUpsertKeybindingResult, +} from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -36,7 +40,7 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal"; -import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import type { ServerUpsertKeybindingInput } from "./server"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -47,6 +51,7 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; +import { ServerSettings, ServerSettingsPatch } from "./settings"; export interface ContextMenuItem { id: T; @@ -160,7 +165,10 @@ export interface NativeApi { }; server: { getConfig: () => Promise; + refreshProviders: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getSettings: () => Promise; + updateSettings: (patch: ServerSettingsPatch) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 6e564fae06..68ca110473 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; import type { ProviderKind } from "./orchestration"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; @@ -26,178 +27,28 @@ export const ProviderModelOptions = Schema.Struct({ }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; -export type EffortOption = { - readonly value: string; - readonly label: string; - readonly isDefault?: true; -}; - -export type ModelCapabilities = { - readonly reasoningEffortLevels: readonly EffortOption[]; - readonly supportsFastMode: boolean; - readonly supportsThinkingToggle: boolean; - readonly promptInjectedEffortLevels: readonly string[]; -}; - -type ModelDefinition = { - readonly slug: string; - readonly name: string; - readonly capabilities: ModelCapabilities; -}; +export const EffortOption = Schema.Struct({ + value: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + isDefault: Schema.optional(Schema.Boolean), +}); +export type EffortOption = typeof EffortOption.Type; -/** - * TODO: This should not be a static array, each provider - * should return its own model list over the WS API. - */ -export const MODEL_OPTIONS_BY_PROVIDER = { - codex: [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4-mini", - name: "GPT-5.4 Mini", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.2", - name: "GPT-5.2", - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "low", label: "Low" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - ], - claudeAgent: [ - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "ultrathink", label: "Ultrathink" }, - ], - supportsFastMode: false, - supportsThinkingToggle: false, - promptInjectedEffortLevels: ["ultrathink"], - }, - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - promptInjectedEffortLevels: [], - }, - }, - ], -} as const satisfies Record; -export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; +export const ModelCapabilities = Schema.Struct({ + reasoningEffortLevels: Schema.Array(EffortOption), + supportsFastMode: Schema.Boolean, + supportsThinkingToggle: Schema.Boolean, + promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), +}); +export type ModelCapabilities = typeof ModelCapabilities.Type; -type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; -export type ModelSlug = BuiltInModelSlug | (string & {}); +export type ModelSlug = string & {}; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", }; -// Backward compatibility for existing Codex-only call sites. -export const MODEL_OPTIONS = MODEL_OPTIONS_BY_PROVIDER.codex; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; /** Per-provider text generation model defaults. */ @@ -230,15 +81,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record [ - provider, - Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), - ]), -) as unknown as Record>; - // ── Provider display names ──────────────────────────────────────────── export const PROVIDER_DISPLAY_NAMES: Record = { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 333d5ca1eb..0b40bb6fdf 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -47,37 +47,20 @@ export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ provider: Schema.Literal("codex"), model: TrimmedNonEmptyString, - options: Schema.optional(CodexModelOptions), + options: Schema.optionalKey(CodexModelOptions), }); export type CodexModelSelection = typeof CodexModelSelection.Type; export const ClaudeModelSelection = Schema.Struct({ provider: Schema.Literal("claudeAgent"), model: TrimmedNonEmptyString, - options: Schema.optional(ClaudeModelOptions), + options: Schema.optionalKey(ClaudeModelOptions), }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); export type ModelSelection = typeof ModelSelection.Type; -export const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyString), - homePath: Schema.optional(TrimmedNonEmptyString), -}); - -export const ClaudeProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyString), - permissionMode: Schema.optional(TrimmedNonEmptyString), - maxThinkingTokens: Schema.optional(NonNegativeInt), -}); - -export const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), - claudeAgent: Schema.optional(ClaudeProviderStartOptions), -}); -export type ProviderStartOptions = typeof ProviderStartOptions.Type; - export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -402,8 +385,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -423,8 +404,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -702,7 +681,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 0c24b1da99..37469984de 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -21,12 +21,6 @@ describe("ProviderSessionStartInput", () => { }, }, runtimeMode: "full-access", - providerOptions: { - codex: { - binaryPath: "/usr/local/bin/codex", - homePath: "/tmp/.codex", - }, - }, }); expect(parsed.runtimeMode).toBe("full-access"); expect(parsed.modelSelection?.provider).toBe("codex"); @@ -36,8 +30,6 @@ describe("ProviderSessionStartInput", () => { } expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); expect(parsed.modelSelection.options?.fastMode).toBe(true); - expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); - expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); it("rejects payloads without runtime mode", () => { @@ -63,13 +55,6 @@ describe("ProviderSessionStartInput", () => { fastMode: true, }, }, - providerOptions: { - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - permissionMode: "plan", - maxThinkingTokens: 12_000, - }, - }, runtimeMode: "full-access", }); expect(parsed.provider).toBe("claudeAgent"); @@ -81,9 +66,6 @@ describe("ProviderSessionStartInput", () => { expect(parsed.modelSelection.options?.thinking).toBe(true); expect(parsed.modelSelection.options?.effort).toBe("max"); expect(parsed.modelSelection.options?.fastMode).toBe(true); - expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); - expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); - expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); expect(parsed.runtimeMode).toBe("full-access"); }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index e28088dc92..16102920d7 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -19,7 +19,6 @@ import { ProviderKind, ProviderRequestKind, ProviderSandboxMode, - ProviderStartOptions, ProviderUserInputAnswers, RuntimeMode, } from "./orchestration"; @@ -55,7 +54,6 @@ export const ProviderSessionStartInput = Schema.Struct({ resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), - providerOptions: Schema.optional(ProviderStartOptions), runtimeMode: RuntimeMode, }); export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..78d0879cd2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -2,7 +2,9 @@ import { Schema } from "effect"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; +import { ModelCapabilities } from "./model"; import { ProviderKind } from "./orchestration"; +import { ServerSettings } from "./settings"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -23,8 +25,8 @@ export type ServerConfigIssue = typeof ServerConfigIssue.Type; const ServerConfigIssues = Schema.Array(ServerConfigIssue); -export const ServerProviderStatusState = Schema.Literals(["ready", "warning", "error"]); -export type ServerProviderStatusState = typeof ServerProviderStatusState.Type; +export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); +export type ServerProviderState = typeof ServerProviderState.Type; export const ServerProviderAuthStatus = Schema.Literals([ "authenticated", @@ -33,25 +35,37 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; -export const ServerProviderStatus = Schema.Struct({ +export const ServerProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + isCustom: Schema.Boolean, + capabilities: Schema.NullOr(ModelCapabilities), +}); +export type ServerProviderModel = typeof ServerProviderModel.Type; + +export const ServerProvider = Schema.Struct({ provider: ProviderKind, - status: ServerProviderStatusState, - available: Schema.Boolean, + enabled: Schema.Boolean, + installed: Schema.Boolean, + version: Schema.NullOr(TrimmedNonEmptyString), + status: ServerProviderState, authStatus: ServerProviderAuthStatus, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + models: Schema.Array(ServerProviderModel), }); -export type ServerProviderStatus = typeof ServerProviderStatus.Type; +export type ServerProvider = typeof ServerProvider.Type; -const ServerProviderStatuses = Schema.Array(ServerProviderStatus); +const ServerProviders = Schema.Array(ServerProvider); export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, - providers: ServerProviderStatuses, + providers: ServerProviders, availableEditors: Schema.Array(EditorId), + settings: ServerSettings, }); export type ServerConfig = typeof ServerConfig.Type; @@ -66,6 +80,11 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, - providers: ServerProviderStatuses, + settings: Schema.optional(ServerSettings), }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; + +export const ServerProviderUpdatedPayload = Schema.Struct({ + providers: ServerProviders, +}); +export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts new file mode 100644 index 0000000000..8ce01f630c --- /dev/null +++ b/packages/contracts/src/settings.ts @@ -0,0 +1,153 @@ +import { Effect } from "effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; +import { + ClaudeModelOptions, + CodexModelOptions, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, +} from "./model"; +import { ModelSelection } from "./orchestration"; + +// ── Client Settings (local-only) ─────────────────────────────── + +export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); +export type TimestampFormat = typeof TimestampFormat.Type; +export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; + +export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); +export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; +export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; + +export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); +export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; +export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; + +export const ClientSettingsSchema = Schema.Struct({ + confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( + Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), + ), + sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( + Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), + ), + timestampFormat: TimestampFormat.pipe(Schema.withDecodingDefault(() => DEFAULT_TIMESTAMP_FORMAT)), +}); +export type ClientSettings = typeof ClientSettingsSchema.Type; + +export const DEFAULT_CLIENT_SETTINGS: ClientSettings = Schema.decodeSync(ClientSettingsSchema)({}); + +// ── Server Settings (server-authoritative) ──────────────────── + +export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); +export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +const makeBinaryPathSetting = (fallback: string) => + TrimmedString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(value || fallback), + encode: (value) => Effect.succeed(value), + }), + ), + Schema.withDecodingDefault(() => fallback), + ); + +export const CodexSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("codex"), + homePath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type CodexSettings = typeof CodexSettings.Type; + +export const ClaudeSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("claude"), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type ClaudeSettings = typeof ClaudeSettings.Type; + +export const ServerSettings = Schema.Struct({ + enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + defaultThreadEnvMode: ThreadEnvMode.pipe( + Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), + ), + textGenerationModelSelection: ModelSelection.pipe( + Schema.withDecodingDefault(() => ({ + provider: "codex" as const, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + })), + ), + + // Provider specific settings + providers: Schema.Struct({ + codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), + claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + }).pipe(Schema.withDecodingDefault(() => ({}))), +}); +export type ServerSettings = typeof ServerSettings.Type; + +export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerSettings)({}); + +// ── Unified type ───────────────────────────────────────────────────── + +export type UnifiedSettings = ServerSettings & ClientSettings; +export const DEFAULT_UNIFIED_SETTINGS: UnifiedSettings = { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, +}; + +// ── Server Settings Patch (replace with a Schema.deepPartial if available) ────────────────────────────────────────── + +const CodexModelOptionsPatch = Schema.Struct({ + reasoningEffort: Schema.optionalKey(CodexModelOptions.fields.reasoningEffort), + fastMode: Schema.optionalKey(CodexModelOptions.fields.fastMode), +}); + +const ClaudeModelOptionsPatch = Schema.Struct({ + thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking), + effort: Schema.optionalKey(ClaudeModelOptions.fields.effort), + fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode), +}); + +const ModelSelectionPatch = Schema.Union([ + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("codex")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(CodexModelOptionsPatch), + }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("claudeAgent")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(ClaudeModelOptionsPatch), + }), +]); + +const CodexSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + homePath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +const ClaudeSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +export const ServerSettingsPatch = Schema.Struct({ + enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), + defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), + providers: Schema.optionalKey( + Schema.Struct({ + codex: Schema.optionalKey(CodexSettingsPatch), + claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + }), + ), +}); +export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 2030dad4e5..0d8d4dbec2 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -119,6 +119,25 @@ it.effect("accepts git.actionProgress push envelopes", () => }), ); +it.effect("accepts server.providersUpdated push envelopes", () => + Effect.gen(function* () { + const parsed = yield* decodeWsResponse({ + type: "push", + sequence: 4, + channel: WS_CHANNELS.serverProvidersUpdated, + data: { + providers: [], + }, + }); + + if (!("type" in parsed) || parsed.type !== "push") { + assert.fail("expected websocket response to decode as a push envelope"); + } + + assert.strictEqual(parsed.channel, WS_CHANNELS.serverProvidersUpdated); + }), +); + it.effect("rejects push envelopes when channel payload does not match the channel schema", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 45ef0512da..2bcc37f7c6 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -37,7 +37,8 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { ServerConfigUpdatedPayload, ServerProviderUpdatedPayload } from "./server"; +import { ServerSettingsPatch } from "./settings"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -75,7 +76,10 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverRefreshProviders: "server.refreshProviders", serverUpsertKeybinding: "server.upsertKeybinding", + serverGetSettings: "server.getSettings", + serverUpdateSettings: "server.updateSettings", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -85,6 +89,7 @@ export const WS_CHANNELS = { terminalEvent: "terminal.event", serverWelcome: "server.welcome", serverConfigUpdated: "server.configUpdated", + serverProvidersUpdated: "server.providersUpdated", } as const; // -- Tagged Union of all request body schemas ───────────────────────── @@ -140,7 +145,10 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverRefreshProviders, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), ]); export const WebSocketRequest = Schema.Struct({ @@ -174,6 +182,7 @@ export type WsWelcomePayload = typeof WsWelcomePayload.Type; export interface WsPushPayloadByChannel { readonly [WS_CHANNELS.serverWelcome]: WsWelcomePayload; readonly [WS_CHANNELS.serverConfigUpdated]: typeof ServerConfigUpdatedPayload.Type; + readonly [WS_CHANNELS.serverProvidersUpdated]: typeof ServerProviderUpdatedPayload.Type; readonly [WS_CHANNELS.gitActionProgress]: typeof GitActionProgressEvent.Type; readonly [WS_CHANNELS.terminalEvent]: typeof TerminalEvent.Type; readonly [ORCHESTRATION_WS_CHANNELS.domainEvent]: OrchestrationEvent; @@ -198,6 +207,10 @@ export const WsPushServerConfigUpdated = makeWsPushSchema( WS_CHANNELS.serverConfigUpdated, ServerConfigUpdatedPayload, ); +export const WsPushServerProvidersUpdated = makeWsPushSchema( + WS_CHANNELS.serverProvidersUpdated, + ServerProviderUpdatedPayload, +); export const WsPushGitActionProgress = makeWsPushSchema( WS_CHANNELS.gitActionProgress, GitActionProgressEvent, @@ -212,6 +225,7 @@ export const WsPushChannelSchema = Schema.Literals([ WS_CHANNELS.gitActionProgress, WS_CHANNELS.serverWelcome, WS_CHANNELS.serverConfigUpdated, + WS_CHANNELS.serverProvidersUpdated, WS_CHANNELS.terminalEvent, ORCHESTRATION_WS_CHANNELS.domainEvent, ]); @@ -220,6 +234,7 @@ export type WsPushChannelSchema = typeof WsPushChannelSchema.Type; export const WsPush = Schema.Union([ WsPushServerWelcome, WsPushServerConfigUpdated, + WsPushServerProvidersUpdated, WsPushGitActionProgress, WsPushTerminalEvent, WsPushOrchestrationDomainEvent, diff --git a/packages/shared/package.json b/packages/shared/package.json index 02ae794d64..d34d1ce453 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -31,6 +31,10 @@ "./schemaJson": { "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" + }, + "./Struct": { + "types": "./src/Struct.ts", + "import": "./src/Struct.ts" } }, "scripts": { diff --git a/packages/shared/src/Struct.ts b/packages/shared/src/Struct.ts new file mode 100644 index 0000000000..f703bcabfa --- /dev/null +++ b/packages/shared/src/Struct.ts @@ -0,0 +1,23 @@ +import * as P from "effect/Predicate"; + +export type DeepPartial = T extends readonly (infer U)[] + ? readonly DeepPartial[] + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + +export function deepMerge>(current: T, patch: DeepPartial): T { + if (!P.isObject(current) || !P.isObject(patch)) { + return patch as T; + } + + const next = { ...current } as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + + const existing = next[key]; + next[key] = P.isObject(existing) && P.isObject(value) ? deepMerge(existing, value) : value; + } + + return next as T; +} diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index d62a273c2c..31f0d0a112 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -2,31 +2,46 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, - MODEL_OPTIONS, - MODEL_OPTIONS_BY_PROVIDER, - CODEX_REASONING_EFFORT_OPTIONS, + type ModelCapabilities, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, - getDefaultModel, - getModelCapabilities, - getModelOptions, + getDefaultEffort, + hasEffortLevel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, - resolveSelectableModel, resolveModelSlug, resolveModelSlugForProvider, - getDefaultEffort, - hasEffortLevel, + resolveSelectableModel, + trimOrNull, } from "./model"; +const codexCaps: ModelCapabilities = { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], +}; + +const claudeCaps: ModelCapabilities = { + reasoningEffortLevels: [ + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "ultrathink", label: "Ultrathink" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: ["ultrathink"], +}; + describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); - expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); }); it("returns null for empty or missing values", () => { @@ -35,290 +50,62 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug(null)).toBeNull(); expect(normalizeModelSlug(undefined)).toBeNull(); }); - - it("preserves non-aliased model slugs", () => { - expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2"); - expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); - }); - - it("does not leak prototype properties as aliases", () => { - expect(normalizeModelSlug("toString")).toBe("toString"); - expect(normalizeModelSlug("constructor")).toBe("constructor"); - }); - - it("uses provider-specific aliases", () => { - expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-4-6"); - expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); - expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); - }); }); describe("resolveModelSlug", () => { - it("returns default only when the model is missing", () => { + it("returns defaults when the model is missing", () => { expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); - expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL); - }); - - it("preserves unknown custom models", () => { - expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); - expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL); - }); - - it("resolves only supported model options", () => { - for (const model of MODEL_OPTIONS) { - expect(resolveModelSlug(model.slug)).toBe(model.slug); - } - }); - - it("supports provider-aware resolution", () => { expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( DEFAULT_MODEL_BY_PROVIDER.claudeAgent, ); - expect(resolveModelSlugForProvider("claudeAgent", "sonnet")).toBe("claude-sonnet-4-6"); - expect(resolveModelSlugForProvider("claudeAgent", "gpt-5.3-codex")).toBe( - DEFAULT_MODEL_BY_PROVIDER.claudeAgent, - ); }); - it("keeps codex defaults for backward compatibility", () => { - expect(getDefaultModel()).toBe(DEFAULT_MODEL); - expect(getModelOptions()).toEqual(MODEL_OPTIONS); - expect(getModelOptions("claudeAgent")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeAgent); + it("preserves normalized unknown models", () => { + expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); }); }); describe("resolveSelectableModel", () => { - it("resolves exact slug matches", () => { - expect( - resolveSelectableModel("codex", "gpt-5.3-codex", [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - ]), - ).toBe("gpt-5.3-codex"); - }); - - it("resolves case-insensitive display-name matches", () => { - expect( - resolveSelectableModel("codex", "gpt-5.3 codex", [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - ]), - ).toBe("gpt-5.3-codex"); - }); - - it("resolves provider-specific aliases after normalization", () => { - expect( - resolveSelectableModel("claudeAgent", "sonnet", [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - ]), - ).toBe("claude-sonnet-4-6"); - }); - - it("returns null for empty input", () => { - expect(resolveSelectableModel("codex", "", [{ slug: "gpt-5.4", name: "GPT-5.4" }])).toBeNull(); - expect( - resolveSelectableModel("codex", " ", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), - ).toBeNull(); - expect( - resolveSelectableModel("codex", null, [{ slug: "gpt-5.4", name: "GPT-5.4" }]), - ).toBeNull(); - }); - - it("returns null for unknown values that are not present in options", () => { - expect( - resolveSelectableModel("codex", "gpt-4.1", [{ slug: "gpt-5.4", name: "GPT-5.4" }]), - ).toBeNull(); - }); - - it("does not accept normalized custom-looking slugs unless they exist in options", () => { - expect( - resolveSelectableModel("codex", "custom/internal-model", [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - ]), - ).toBeNull(); - }); - - it("respects provider boundaries", () => { - expect( - resolveSelectableModel("codex", "sonnet", [{ slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }]), - ).toBeNull(); - expect( - resolveSelectableModel("claudeAgent", "5.3", [ - { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, - ]), - ).toBeNull(); - }); -}); - -describe("getModelCapabilities reasoningEffortLevels", () => { - const values = (provider: "codex" | "claudeAgent", model: string | null) => - getModelCapabilities(provider, model).reasoningEffortLevels.map((l) => l.value); - - it("returns codex reasoning options for codex", () => { - expect(values("codex", "gpt-5.4")).toEqual([...CODEX_REASONING_EFFORT_OPTIONS]); - }); - - it("returns claude effort options for Opus 4.6", () => { - expect(values("claudeAgent", "claude-opus-4-6")).toEqual([ - "low", - "medium", - "high", - "max", - "ultrathink", - ]); - }); - - it("returns claude effort options for Sonnet 4.6", () => { - expect(values("claudeAgent", "claude-sonnet-4-6")).toEqual([ - "low", - "medium", - "high", - "ultrathink", - ]); - }); - - it("returns no claude effort options for Haiku 4.5", () => { - expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]); - }); - - it("co-locates labels with effort values", () => { - const levels = getModelCapabilities("claudeAgent", "claude-opus-4-6").reasoningEffortLevels; - const high = levels.find((l) => l.value === "high"); - expect(high).toEqual({ value: "high", label: "High", isDefault: true }); - const xhigh = getModelCapabilities("codex", "gpt-5.4").reasoningEffortLevels.find( - (l) => l.value === "xhigh", - ); - expect(xhigh).toEqual({ value: "xhigh", label: "Extra High" }); + it("resolves exact slugs, labels, and aliases", () => { + const options = [ + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ]; + expect(resolveSelectableModel("codex", "gpt-5.3-codex", options)).toBe("gpt-5.3-codex"); + expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); + expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); }); -describe("getDefaultEffort", () => { - it("returns the default effort from capabilities", () => { - expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high"); - expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high"); - expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull(); +describe("capability helpers", () => { + it("reads default efforts", () => { + expect(getDefaultEffort(codexCaps)).toBe("high"); + expect(getDefaultEffort(claudeCaps)).toBe("high"); }); -}); -describe("hasEffortLevel", () => { - it("validates effort against model capabilities", () => { - const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6"); - expect(hasEffortLevel(opusCaps, "max")).toBe(true); - expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false); - - const codexCaps = getModelCapabilities("codex", "gpt-5.4"); + it("checks effort support", () => { expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); expect(hasEffortLevel(codexCaps, "max")).toBe(false); }); }); -describe("applyClaudePromptEffortPrefix", () => { - it("prefixes ultrathink prompts exactly once", () => { - expect(applyClaudePromptEffortPrefix("Investigate this", "ultrathink")).toBe( - "Ultrathink:\nInvestigate this", - ); - expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate this", "ultrathink")).toBe( - "Ultrathink:\nInvestigate this", - ); - }); - - it("leaves non-ultrathink prompts unchanged", () => { - expect(applyClaudePromptEffortPrefix("Investigate this", "high")).toBe("Investigate this"); - }); -}); - -describe("normalizeCodexModelOptions", () => { - it("drops default-only codex options", () => { - expect( - normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }), - ).toBeUndefined(); - }); - - it("preserves non-default codex options", () => { - expect( - normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "xhigh", fastMode: true }), - ).toEqual({ - reasoningEffort: "xhigh", - fastMode: true, - }); - }); -}); - -describe("normalizeClaudeModelOptions", () => { - it("drops unsupported fast mode and max effort for Sonnet", () => { - expect( - normalizeClaudeModelOptions("claude-sonnet-4-6", { - effort: "max", - fastMode: true, - }), - ).toBeUndefined(); - }); - - it("keeps the Haiku thinking toggle and removes unsupported effort", () => { - expect( - normalizeClaudeModelOptions("claude-haiku-4-5", { - thinking: false, - effort: "high", - }), - ).toEqual({ - thinking: false, - }); - }); -}); - -describe("getModelCapabilities Claude capability flags", () => { - it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; - expect(has("claude-opus-4-6")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(true); - expect(has("claude-haiku-4-5")).toBe(false); - expect(has(undefined)).toBe(false); - }); - - it("only enables max effort for Opus 4.6", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max"); - expect(has("claude-opus-4-6")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(false); - expect(has("claude-haiku-4-5")).toBe(false); - expect(has(undefined)).toBe(false); - }); - - it("only enables Claude fast mode for Opus 4.6", () => { - const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode; - expect(has("claude-opus-4-6")).toBe(true); - expect(has("opus")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(false); - expect(has("claude-haiku-4-5")).toBe(false); - expect(has(undefined)).toBe(false); - }); - - it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; - expect(has("claude-opus-4-6")).toBe(true); - expect(has("claude-sonnet-4-6")).toBe(true); - expect(has("claude-haiku-4-5")).toBe(false); +describe("misc helpers", () => { + it("detects ultrathink prompts", () => { + expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); + expect(isClaudeUltrathinkPrompt("Investigate")).toBe(false); }); - it("only enables the Claude thinking toggle for Haiku 4.5", () => { - const has = (m: string | undefined) => - getModelCapabilities("claudeAgent", m).supportsThinkingToggle; - expect(has("claude-opus-4-6")).toBe(false); - expect(has("claude-sonnet-4-6")).toBe(false); - expect(has("claude-haiku-4-5")).toBe(true); - expect(has("haiku")).toBe(true); - expect(has(undefined)).toBe(false); + it("prefixes ultrathink prompts once", () => { + expect(applyClaudePromptEffortPrefix("Investigate", "ultrathink")).toBe( + "Ultrathink:\nInvestigate", + ); + expect(applyClaudePromptEffortPrefix("Ultrathink:\nInvestigate", "ultrathink")).toBe( + "Ultrathink:\nInvestigate", + ); }); -}); -describe("isClaudeUltrathinkPrompt", () => { - it("detects ultrathink prompts case-insensitively", () => { - expect(isClaudeUltrathinkPrompt("Please ultrathink about this")).toBe(true); - expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); - expect(isClaudeUltrathinkPrompt("Think hard about this")).toBe(false); - expect(isClaudeUltrathinkPrompt(undefined)).toBe(false); + it("trims strings to null", () => { + expect(trimOrNull(" hi ")).toBe("hi"); + expect(trimOrNull(" ")).toBeNull(); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 53ebc856fd..e633aeb293 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,31 +1,17 @@ import { DEFAULT_MODEL_BY_PROVIDER, - MODEL_CAPABILITIES_INDEX, - MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ClaudeModelOptions, type ClaudeCodeEffort, - type CodexModelOptions, type ModelCapabilities, type ModelSlug, type ProviderKind, - CodexReasoningEffort, } from "@t3tools/contracts"; -const MODEL_SLUG_SET_BY_PROVIDER: Record> = { - claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), - codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), -}; - export interface SelectableModelOption { slug: string; name: string; } -export function getModelOptions(provider: ProviderKind = "codex") { - return MODEL_OPTIONS_BY_PROVIDER[provider]; -} - export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { return DEFAULT_MODEL_BY_PROVIDER[provider]; } @@ -42,24 +28,6 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } -// ── Data-driven capability resolver ─────────────────────────────────── - -export function getModelCapabilities( - provider: ProviderKind, - model: string | null | undefined, -): ModelCapabilities { - const slug = normalizeModelSlug(model, provider); - if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { - return MODEL_CAPABILITIES_INDEX[provider][slug]; - } - return { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }; -} - export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -125,10 +93,7 @@ export function resolveModelSlug( if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; } - - return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) - ? normalized - : DEFAULT_MODEL_BY_PROVIDER[provider]; + return normalized; } export function resolveModelSlugForProvider( @@ -145,47 +110,6 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function normalizeCodexModelOptions( - model: string | null | undefined, - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const caps = getModelCapabilities("codex", model); - const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -export function normalizeClaudeModelOptions( - model: string | null | undefined, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const caps = getModelCapabilities("claudeAgent", model); - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; - const thinking = - caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; - const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 190dc097e8..9e38b1f8b8 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -1,4 +1,14 @@ -import { Cause, Exit, Result, Schema, SchemaIssue } from "effect"; +import { + Cause, + Effect, + Exit, + Option, + Result, + Schema, + SchemaGetter, + SchemaIssue, + SchemaTransformation, +} from "effect"; export const decodeJsonResult = >( schema: S, @@ -32,3 +42,55 @@ export const formatSchemaError = (cause: Cause.Cause) => { ? SchemaIssue.makeFormatterDefault()(squashed.issue) : Cause.pretty(cause); }; + +/** + * A `Getter` that parses a lenient JSON string (tolerating trailing commas + * and JS-style comments) into an unknown value. + * + * Mirrors `SchemaGetter.parseJson()` but uses `parseLenientJson` instead + * of `JSON.parse`. + */ +const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => + Effect.try({ + try: () => { + // Strip single-line comments — alternation preserves quoted strings. + let stripped = input.replace( + /("(?:[^"\\]|\\.)*")|\/\/[^\n]*/g, + (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), + ); + + // Strip multi-line comments. + stripped = stripped.replace( + /("(?:[^"\\]|\\.)*")|\/\*[\s\S]*?\*\//g, + (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), + ); + + // Strip trailing commas before `}` or `]`. + stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); + + return Option.some(JSON.parse(stripped)); + }, + catch: (e) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(e) }), + }), +); + +/** + * Schema transformation: lenient JSONC string ↔ unknown. + * + * Same API as `SchemaTransformation.fromJsonString`, but the decode side + * strips trailing commas and JS-style comments before parsing. + * Encoding produces strict JSON via `JSON.stringify`. + */ +export const fromLenientJsonString = new SchemaTransformation.Transformation( + parseLenientJsonGetter, + SchemaGetter.stringifyJson(), +); + +/** + * Build a schema that decodes a lenient JSON string into `A`. + * + * Drop-in replacement for `Schema.fromJsonString(schema)` that tolerates + * trailing commas and comments in the input. + */ +export const fromLenientJson = (schema: S) => + Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString)); From add5f34619b65c3283317d8c77e1feef6fc63267 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 14:31:35 -0700 Subject: [PATCH 02/45] Add Claude context window selection support (#1422) --- .../src/git/Layers/ClaudeTextGeneration.ts | 3 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 6 +- .../src/provider/Layers/ClaudeAdapter.ts | 31 +++-- .../src/provider/Layers/ClaudeProvider.ts | 27 ++-- .../src/provider/Layers/CodexProvider.ts | 14 +- apps/web/src/components/ChatView.tsx | 3 +- .../CompactComposerControlsMenu.browser.tsx | 4 + .../components/chat/ComposerCommandMenu.tsx | 4 +- .../chat/ProviderModelPicker.browser.tsx | 9 +- .../components/chat/ProviderModelPicker.tsx | 6 +- .../components/chat/TraitsPicker.browser.tsx | 4 + apps/web/src/components/chat/TraitsPicker.tsx | 81 +++++++++-- .../chat/composerProviderRegistry.test.tsx | 26 +++- .../chat/composerProviderRegistry.tsx | 30 +--- apps/web/src/composerDraftStore.ts | 24 ++-- apps/web/src/providerModels.ts | 28 ++-- packages/contracts/src/model.ts | 15 +- packages/shared/src/model.test.ts | 131 +++++++++++++++++- packages/shared/src/model.ts | 107 ++++++++++++-- 19 files changed, 414 insertions(+), 139 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 6ffedbf7b4..919c3a323d 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -11,6 +11,7 @@ import { Effect, Layer, Option, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; +import { resolveApiModelId } from "@t3tools/shared/model"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "../Errors.ts"; @@ -103,7 +104,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { "--json-schema", jsonSchemaStr, "--model", - modelSelection.model, + resolveApiModelId(modelSelection), ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), "--dangerously-skip-permissions", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4e8238dbe6..a10a40629c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -347,7 +347,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("ignores unsupported max effort for Sonnet 4.6", () => { + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -365,7 +365,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal(createInput?.options.effort, "high"); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -532,7 +532,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + assert.equal(createInput?.options.effort, "high"); const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); }).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7ab8bc44ab..e7602ea5c4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -40,7 +40,12 @@ import { type UserInputQuestion, ClaudeCodeEffort, } from "@t3tools/contracts"; -import { hasEffortLevel, applyClaudePromptEffortPrefix, trimOrNull } from "@t3tools/shared/model"; +import { + applyClaudePromptEffortPrefix, + resolveApiModelId, + resolveEffort, + trimOrNull, +} from "@t3tools/shared/model"; import { Cause, DateTime, @@ -506,16 +511,15 @@ const CLAUDE_SETTING_SOURCES = [ function buildPromptText(input: ProviderSendTurnInput): string { const rawEffort = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; - const requestedEffort = trimOrNull(rawEffort); const claudeModel = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); + + // For prompt injection, we check if the raw effort is a prompt-injected level (e.g. "ultrathink"). + // resolveEffort strips prompt-injected values (returning the default instead), so we check the raw value directly. + const trimmedEffort = trimOrNull(rawEffort); const promptEffort = - requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 - ? "ultrathink" - : requestedEffort && hasEffortLevel(caps, requestedEffort) - ? requestedEffort - : null; + trimmedEffort && caps.promptInjectedEffortLevels.includes(trimmedEffort) ? trimmedEffort : null; return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -2727,10 +2731,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const claudeBinaryPath = claudeSettings.binaryPath; const modelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; - const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const caps = getClaudeModelCapabilities(modelSelection?.model); - const effort = - requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; + const apiModelId = modelSelection ? resolveApiModelId(modelSelection) : undefined; + const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? + null) as ClaudeCodeEffort | null; const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; const thinking = typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle @@ -2746,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(apiModelId ? { model: apiModelId } : {}), pathToClaudeCodeExecutable: claudeBinaryPath, settingSources: [...CLAUDE_SETTING_SOURCES], ...(effectiveEffort ? { effort: effectiveEffort } : {}), @@ -2840,7 +2844,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { threadId, payload: { config: { - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(apiModelId ? { model: apiModelId } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), @@ -2893,8 +2897,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } if (modelSelection?.model) { + const apiModelId = resolveApiModelId(modelSelection); yield* Effect.tryPromise({ - try: () => context.query.setModel(modelSelection.model), + try: () => context.query.setModel(apiModelId), catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), }); } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e51f5096db..b67b90e879 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -9,7 +9,7 @@ import type { } from "@t3tools/contracts"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { getDefaultEffort, hasEffortLevel, trimOrNull } from "@t3tools/shared/model"; +import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, @@ -42,6 +42,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, }, @@ -58,6 +62,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, }, @@ -69,6 +77,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], } satisfies ModelCapabilities, }, @@ -81,6 +90,7 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], } ); @@ -91,23 +101,16 @@ export function normalizeClaudeModelOptions( modelOptions: ClaudeModelOptions | null | undefined, ): ClaudeModelOptions | undefined { const caps = getClaudeModelCapabilities(model); - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; + const effort = resolveEffort(caps, modelOptions?.effort); const thinking = caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); const nextOptions: ClaudeModelOptions = { ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 913fbb58d5..6497469a2a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { getDefaultEffort, trimOrNull } from "@t3tools/shared/model"; +import { resolveEffort } from "@t3tools/shared/model"; import { buildServerProvider, @@ -48,6 +48,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -64,6 +65,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -80,6 +82,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -96,6 +99,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -112,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -128,6 +133,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -140,6 +146,7 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], } ); @@ -150,11 +157,10 @@ export function normalizeCodexModelOptions( modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { const caps = getCodexModelCapabilities(model); - const defaultReasoningEffort = getDefaultEffort(caps); - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); const fastModeEnabled = modelOptions?.fastMode === true; const nextOptions: CodexModelOptions = { - ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ...(reasoningEffort ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } : {}), ...(fastModeEnabled ? { fastMode: true } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 849f59e088..b0129ab75a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5,7 +5,6 @@ import { type MessageId, type ModelSelection, type ProjectScript, - type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, @@ -3105,7 +3104,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const onProviderModelSelect = useCallback( - (provider: ProviderKind, model: ModelSlug) => { + (provider: ProviderKind, model: string) => { if (!activeThread) return; if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 8770e58138..aa3550e007 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -60,6 +60,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -71,6 +72,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -87,6 +89,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -103,6 +106,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 818c3c20f8..7af4e6da43 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,4 +1,4 @@ -import { type ProjectEntry, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ProjectEntry, type ProviderKind } from "@t3tools/contracts"; import { memo } from "react"; import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic"; import { BotIcon } from "lucide-react"; @@ -27,7 +27,7 @@ export type ComposerCommandItem = id: string; type: "model"; provider: ProviderKind; - model: ModelSlug; + model: string; label: string; description: string; }; diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index fe878e7c18..679cbff321 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -33,6 +33,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -44,6 +45,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -71,6 +73,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -87,6 +90,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -98,6 +102,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -107,7 +112,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ async function mountPicker(props: { provider: ProviderKind; - model: ModelSlug; + model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; triggerVariant?: "ghost" | "outline"; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 5a09defc72..565a9d399d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -52,7 +52,7 @@ function providerIconClassName( export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; - model: ModelSlug; + model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; modelOptionsByProvider: Record>; @@ -61,7 +61,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; - onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index bd8c61ee56..99d09fd634 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -49,6 +49,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -77,6 +78,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -93,6 +95,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -104,6 +107,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5fd97b8cde..a3b6cbb48f 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -11,7 +11,9 @@ import { isClaudeUltrathinkPrompt, trimOrNull, getDefaultEffort, - hasEffortLevel, + getDefaultContextWindow, + hasContextWindowOption, + resolveEffort, } from "@t3tools/shared/model"; import { memo, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; @@ -53,6 +55,16 @@ function getRawEffort( return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } +function getRawContextWindow( + provider: ProviderKind, + modelOptions: ProviderOptions | null | undefined, +): string | null { + if (provider === "claudeAgent") { + return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.contextWindow); + } + return null; +} + function buildNextOptions( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, @@ -78,21 +90,10 @@ function getSelectedTraits( : caps.reasoningEffortLevels.filter( (option) => !caps.promptInjectedEffortLevels.includes(option.value), ); - const defaultEffort = getDefaultEffort(caps); // Resolve effort from options (provider-specific key) - const resolvedEffort = getRawEffort(provider, modelOptions); - - // Filter out prompt-injected efforts from the "current effort" display - const isPromptInjected = resolvedEffort - ? caps.promptInjectedEffortLevels.includes(resolvedEffort) - : false; - const effort = - resolvedEffort && !isPromptInjected && hasEffortLevel(caps, resolvedEffort) - ? resolvedEffort - : defaultEffort && hasEffortLevel(caps, defaultEffort) - ? defaultEffort - : null; + const rawEffort = getRawEffort(provider, modelOptions); + const effort = resolveEffort(caps, rawEffort) ?? null; // Thinking toggle (only for models that support it) const thinkingEnabled = caps.supportsThinkingToggle @@ -104,6 +105,15 @@ function getSelectedTraits( caps.supportsFastMode && (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true; + // Context window + const contextWindowOptions = caps.contextWindowOptions; + const rawContextWindow = getRawContextWindow(provider, modelOptions); + const defaultContextWindow = getDefaultContextWindow(caps); + const contextWindow = + rawContextWindow && hasContextWindowOption(caps, rawContextWindow) + ? rawContextWindow + : defaultContextWindow; + // Prompt-controlled effort (e.g. ultrathink in prompt text) const ultrathinkPromptControlled = allowPromptInjectedEffort && @@ -116,6 +126,9 @@ function getSelectedTraits( effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, }; } @@ -159,6 +172,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); @@ -194,7 +210,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null) { + if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { return null; } @@ -258,6 +274,33 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ) : null} + {contextWindowOptions.length > 1 ? ( + <> + + +
+ Context Window +
+ { + updateModelOptions( + buildNextOptions(provider, modelOptions, { + contextWindow: value, + }), + ); + }} + > + {contextWindowOptions.map((option) => ( + + {option.label} + {option.value === defaultContextWindow ? " (default)" : ""} + + ))} + +
+ + ) : null} ); }); @@ -281,12 +324,19 @@ export const TraitsPicker = memo(function TraitsPicker({ effortLevels, thinkingEnabled, fastModeEnabled, + contextWindowOptions, + contextWindow, + defaultContextWindow, ultrathinkPromptControlled, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) : null; + const contextWindowLabel = + contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow + ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) + : null; const triggerLabel = [ ultrathinkPromptControlled ? "Ultrathink" @@ -296,6 +346,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ? null : `Thinking ${thinkingEnabled ? "On" : "Off"}`, ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), + ...(contextWindowLabel ? [contextWindowLabel] : []), ] .filter(Boolean) .join(" · "); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index d5fbb1333a..cc17335cd5 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -16,6 +16,7 @@ const CODEX_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -35,6 +36,7 @@ const CLAUDE_MODELS: ReadonlyArray = [ ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -51,6 +53,7 @@ const CLAUDE_MODELS: ReadonlyArray = [ ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: ["ultrathink"], }, }, @@ -62,6 +65,7 @@ const CLAUDE_MODELS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [], promptInjectedEffortLevels: [], }, }, @@ -80,7 +84,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "codex", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + reasoningEffort: "high", + }, }); }); @@ -125,12 +131,13 @@ describe("getComposerProviderState", () => { provider: "codex", promptEffort: "high", modelOptionsForDispatch: { + reasoningEffort: "high", fastMode: true, }, }); }); - it("drops explicit codex default/off overrides from dispatch while keeping the selected effort label", () => { + it("preserves codex default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "codex", model: "gpt-5.4", @@ -147,7 +154,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "codex", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + reasoningEffort: "high", + }, }); }); @@ -163,7 +172,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + effort: "high", + }, }); }); @@ -232,12 +243,13 @@ describe("getComposerProviderState", () => { provider: "claudeAgent", promptEffort: "high", modelOptionsForDispatch: { + effort: "high", fastMode: true, }, }); }); - it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { + it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -254,7 +266,9 @@ describe("getComposerProviderState", () => { expect(state).toEqual({ provider: "claudeAgent", promptEffort: "high", - modelOptionsForDispatch: undefined, + modelOptionsForDispatch: { + effort: "high", + }, }); }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 2cebd8d4f4..1a2080d441 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,16 +1,10 @@ import { - type ModelSlug, type ProviderKind, type ProviderModelOptions, type ServerProviderModel, type ThreadId, } from "@t3tools/contracts"; -import { - isClaudeUltrathinkPrompt, - trimOrNull, - getDefaultEffort, - hasEffortLevel, -} from "@t3tools/shared/model"; +import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; import { getProviderModelCapabilities, @@ -21,7 +15,7 @@ import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; - model: ModelSlug; + model: string; models: ReadonlyArray; prompt: string; modelOptions: ProviderModelOptions | null | undefined; @@ -40,7 +34,7 @@ type ProviderRegistryEntry = { getState: (input: ComposerProviderStateInput) => ComposerProviderState; renderTraitsMenuContent: (input: { threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -48,7 +42,7 @@ type ProviderRegistryEntry = { }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -72,17 +66,7 @@ function getProviderStateFromCapabilities( : null : null; - const draftEffort = trimOrNull(rawEffort); - const defaultEffort = getDefaultEffort(caps); - const isPromptInjected = draftEffort - ? caps.promptInjectedEffortLevels.includes(draftEffort) - : false; - const promptEffort = - draftEffort && !isPromptInjected && hasEffortLevel(caps, draftEffort) - ? draftEffort - : defaultEffort && hasEffortLevel(caps, defaultEffort) - ? defaultEffort - : null; + const promptEffort = resolveEffort(caps, rawEffort) ?? null; // Normalize options for dispatch const normalizedOptions = @@ -180,7 +164,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; @@ -199,7 +183,7 @@ export function renderProviderTraitsMenuContent(input: { export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; - model: ModelSlug; + model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; prompt: string; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3d54c526f1..17b06e7bd1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -2,7 +2,7 @@ import { CODEX_REASONING_EFFORT_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, - type ModelSlug, + DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ProjectId, ProviderInteractionMode, @@ -15,7 +15,7 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { getDefaultModel, normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -263,7 +263,7 @@ interface ComposerDraftStoreState { } export interface EffectiveComposerModelState { - selectedModel: ModelSlug; + selectedModel: string; modelOptions: ProviderModelOptions | null; } @@ -475,12 +475,20 @@ function normalizeProviderModelOptions( : claudeCandidate?.fastMode === false ? false : undefined; + const claudeContextWindow = + typeof claudeCandidate?.contextWindow === "string" && claudeCandidate.contextWindow.length > 0 + ? claudeCandidate.contextWindow + : undefined; const claude = - claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined + claudeThinking !== undefined || + claudeEffort !== undefined || + claudeFastMode !== undefined || + claudeContextWindow !== undefined ? { ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), + ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), } : undefined; @@ -594,7 +602,7 @@ function legacyToModelSelectionByProvider( model: modelSelection?.provider === provider ? modelSelection.model - : getDefaultModel(provider), + : DEFAULT_MODEL_BY_PROVIDER[provider], options, }; } @@ -1676,7 +1684,7 @@ export const useComposerDraftStore = create()( if (opts) { nextMap[provider] = { provider, - model: current?.model ?? getDefaultModel(provider), + model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], options: opts, }; } else if (current?.options) { @@ -1726,7 +1734,7 @@ export const useComposerDraftStore = create()( if (providerOpts) { nextMap[normalizedProvider] = { provider: normalizedProvider, - model: currentForProvider?.model ?? getDefaultModel(normalizedProvider), + model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], options: providerOpts, }; } else if (currentForProvider?.options) { @@ -1744,7 +1752,7 @@ export const useComposerDraftStore = create()( base.modelSelectionByProvider[normalizedProvider] ?? ({ provider: normalizedProvider, - model: getDefaultModel(normalizedProvider), + model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], } as ModelSelection); if (providerOpts) { nextStickyMap[normalizedProvider] = { diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index a925ed690f..f2e8692eff 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -7,17 +7,13 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { - getDefaultEffort, - hasEffortLevel, - normalizeModelSlug, - trimOrNull, -} from "@t3tools/shared/model"; +import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; const EMPTY_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }; @@ -78,11 +74,10 @@ export function normalizeCodexModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined { - const defaultReasoningEffort = getDefaultEffort(caps); - const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); const fastModeEnabled = modelOptions?.fastMode === true; const nextOptions: CodexModelOptions = { - ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ...(reasoningEffort ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } : {}), ...(fastModeEnabled ? { fastMode: true } : {}), @@ -94,23 +89,16 @@ export function normalizeClaudeModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: ClaudeModelOptions | null | undefined, ): ClaudeModelOptions | undefined { - const defaultReasoningEffort = getDefaultEffort(caps); - const resolvedEffort = trimOrNull(modelOptions?.effort); - const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); - const effort = - resolvedEffort && - !isPromptInjected && - hasEffortLevel(caps, resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; + const effort = resolveEffort(caps, modelOptions?.effort); const thinking = caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); const nextOptions: ClaudeModelOptions = { ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 68ca110473..e62a957e05 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -18,6 +18,7 @@ export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), + contextWindow: Schema.optional(Schema.String), }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; @@ -34,17 +35,23 @@ export const EffortOption = Schema.Struct({ }); export type EffortOption = typeof EffortOption.Type; +export const ContextWindowOption = Schema.Struct({ + value: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + isDefault: Schema.optional(Schema.Boolean), +}); +export type ContextWindowOption = typeof ContextWindowOption.Type; + export const ModelCapabilities = Schema.Struct({ reasoningEffortLevels: Schema.Array(EffortOption), supportsFastMode: Schema.Boolean, supportsThinkingToggle: Schema.Boolean, + contextWindowOptions: Schema.Array(ContextWindowOption), promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), }); export type ModelCapabilities = typeof ModelCapabilities.Type; -export type ModelSlug = string & {}; - -export const DEFAULT_MODEL_BY_PROVIDER: Record = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", }; @@ -57,7 +64,7 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record> = { +export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { codex: { "5.4": "gpt-5.4", "5.3": "gpt-5.3-codex", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 31f0d0a112..535601762d 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,16 +1,17 @@ import { describe, expect, it } from "vitest"; -import { - DEFAULT_MODEL, - DEFAULT_MODEL_BY_PROVIDER, - type ModelCapabilities, -} from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, + getDefaultContextWindow, getDefaultEffort, + hasContextWindowOption, hasEffortLevel, isClaudeUltrathinkPrompt, normalizeModelSlug, + resolveApiModelId, + resolveContextWindow, + resolveEffort, resolveModelSlug, resolveModelSlugForProvider, resolveSelectableModel, @@ -24,6 +25,7 @@ const codexCaps: ModelCapabilities = { ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [], promptInjectedEffortLevels: [], }; @@ -35,6 +37,10 @@ const claudeCaps: ModelCapabilities = { ], supportsFastMode: false, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], promptInjectedEffortLevels: ["ultrathink"], }; @@ -54,14 +60,15 @@ describe("normalizeModelSlug", () => { describe("resolveModelSlug", () => { it("returns defaults when the model is missing", () => { - expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL); + expect(resolveModelSlug(undefined, "codex")).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(resolveModelSlugForProvider("claudeAgent", undefined)).toBe( DEFAULT_MODEL_BY_PROVIDER.claudeAgent, ); }); it("preserves normalized unknown models", () => { - expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); + expect(resolveModelSlug("custom/internal-model", "codex")).toBe("custom/internal-model"); }); }); @@ -89,6 +96,42 @@ describe("capability helpers", () => { }); }); +describe("resolveEffort", () => { + it("returns the explicit value when supported and not prompt-injected", () => { + expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); + expect(resolveEffort(codexCaps, "high")).toBe("high"); + expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); + }); + + it("falls back to default when value is unsupported", () => { + expect(resolveEffort(codexCaps, "bogus")).toBe("high"); + expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); + }); + + it("returns the default when no value is provided", () => { + expect(resolveEffort(codexCaps, undefined)).toBe("high"); + expect(resolveEffort(codexCaps, null)).toBe("high"); + expect(resolveEffort(codexCaps, "")).toBe("high"); + expect(resolveEffort(codexCaps, " ")).toBe("high"); + }); + + it("excludes prompt-injected efforts and falls back to default", () => { + expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); + }); + + it("returns undefined for models with no effort levels", () => { + const noCaps: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }; + expect(resolveEffort(noCaps, undefined)).toBeUndefined(); + expect(resolveEffort(noCaps, "high")).toBeUndefined(); + }); +}); + describe("misc helpers", () => { it("detects ultrathink prompts", () => { expect(isClaudeUltrathinkPrompt("Ultrathink:\nInvestigate")).toBe(true); @@ -109,3 +152,77 @@ describe("misc helpers", () => { expect(trimOrNull(" ")).toBeNull(); }); }); + +describe("context window helpers", () => { + it("reads default context window", () => { + expect(getDefaultContextWindow(claudeCaps)).toBe("1m"); + }); + + it("returns null for models without context window options", () => { + expect(getDefaultContextWindow(codexCaps)).toBeNull(); + }); + + it("checks context window support", () => { + expect(hasContextWindowOption(claudeCaps, "1m")).toBe(true); + expect(hasContextWindowOption(claudeCaps, "200k")).toBe(true); + expect(hasContextWindowOption(claudeCaps, "bogus")).toBe(false); + expect(hasContextWindowOption(codexCaps, "1m")).toBe(false); + }); +}); + +describe("resolveContextWindow", () => { + it("returns the explicit value when supported", () => { + expect(resolveContextWindow(claudeCaps, "200k")).toBe("200k"); + expect(resolveContextWindow(claudeCaps, "1m")).toBe("1m"); + }); + + it("falls back to default when value is unsupported", () => { + expect(resolveContextWindow(claudeCaps, "bogus")).toBe("1m"); + }); + + it("returns the default when no value is provided", () => { + expect(resolveContextWindow(claudeCaps, undefined)).toBe("1m"); + expect(resolveContextWindow(claudeCaps, null)).toBe("1m"); + expect(resolveContextWindow(claudeCaps, "")).toBe("1m"); + }); + + it("returns undefined for models with no context window options", () => { + expect(resolveContextWindow(codexCaps, undefined)).toBeUndefined(); + expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); + }); +}); + +describe("resolveApiModelId", () => { + it("appends [1m] suffix for 1m context window", () => { + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "1m" }, + }), + ).toBe("claude-opus-4-6[1m]"); + }); + + it("returns the model as-is for 200k context window", () => { + expect( + resolveApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "200k" }, + }), + ).toBe("claude-opus-4-6"); + }); + + it("returns the model as-is when no context window is set", () => { + expect(resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( + "claude-opus-4-6", + ); + expect( + resolveApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6", options: {} }), + ).toBe("claude-opus-4-6"); + }); + + it("returns the model as-is for Codex selections", () => { + expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4"); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index e633aeb293..1598b0407d 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -3,7 +3,7 @@ import { MODEL_SLUG_ALIASES_BY_PROVIDER, type ClaudeCodeEffort, type ModelCapabilities, - type ModelSlug, + type ModelSelection, type ProviderKind, } from "@t3tools/contracts"; @@ -12,10 +12,6 @@ export interface SelectableModelOption { name: string; } -export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { - return DEFAULT_MODEL_BY_PROVIDER[provider]; -} - // ── Effort helpers ──────────────────────────────────────────────────── /** Check whether a capabilities object includes a given effort value. */ @@ -28,6 +24,65 @@ export function getDefaultEffort(caps: ModelCapabilities): string | null { return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null; } +/** + * Resolve a raw effort option against capabilities. + * + * Returns the effective effort value — the explicit value if supported and not + * prompt-injected, otherwise the model's default. Returns `undefined` only + * when the model has no effort levels at all. + * + * Prompt-injected efforts (e.g. "ultrathink") are excluded because they are + * applied via prompt text, not the effort API parameter. + */ +export function resolveEffort( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const defaultValue = getDefaultEffort(caps); + const trimmed = typeof raw === "string" ? raw.trim() : null; + if ( + trimmed && + !caps.promptInjectedEffortLevels.includes(trimmed) && + hasEffortLevel(caps, trimmed) + ) { + return trimmed; + } + return defaultValue ?? undefined; +} + +// ── Context window helpers ─────────────────────────────────────────── + +/** Check whether a capabilities object includes a given context window value. */ +export function hasContextWindowOption(caps: ModelCapabilities, value: string): boolean { + return caps.contextWindowOptions.some((o) => o.value === value); +} + +/** Return the default context window value, or `null` if none is defined. */ +export function getDefaultContextWindow(caps: ModelCapabilities): string | null { + return caps.contextWindowOptions.find((o) => o.isDefault)?.value ?? null; +} + +/** + * Resolve a raw `contextWindow` option against capabilities. + * + * Returns the effective context window value — the explicit value if supported, + * otherwise the model's default. Returns `undefined` only when the model has + * no context window options at all. + * + * Unlike effort levels (where the API has matching defaults), the context + * window requires an explicit API suffix (e.g. `[1m]`), so we always preserve + * the resolved value to avoid ambiguity between "user chose the default" and + * "not specified". + */ +export function resolveContextWindow( + caps: ModelCapabilities, + raw: string | null | undefined, +): string | undefined { + const defaultValue = getDefaultContextWindow(caps); + if (!raw) return defaultValue ?? undefined; + return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined); +} + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -35,7 +90,7 @@ export function isClaudeUltrathinkPrompt(text: string | null | undefined): boole export function normalizeModelSlug( model: string | null | undefined, provider: ProviderKind = "codex", -): ModelSlug | null { +): string | null { if (typeof model !== "string") { return null; } @@ -45,18 +100,18 @@ export function normalizeModelSlug( return null; } - const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; + const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record; const aliased = Object.prototype.hasOwnProperty.call(aliases, trimmed) ? aliases[trimmed] : undefined; - return typeof aliased === "string" ? aliased : (trimmed as ModelSlug); + return typeof aliased === "string" ? aliased : trimmed; } export function resolveSelectableModel( provider: ProviderKind, value: string | null | undefined, options: ReadonlyArray, -): ModelSlug | null { +): string | null { if (typeof value !== "string") { return null; } @@ -85,10 +140,7 @@ export function resolveSelectableModel( return resolved ? resolved.slug : null; } -export function resolveModelSlug( - model: string | null | undefined, - provider: ProviderKind = "codex", -): ModelSlug { +export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { const normalized = normalizeModelSlug(model, provider); if (!normalized) { return DEFAULT_MODEL_BY_PROVIDER[provider]; @@ -99,7 +151,7 @@ export function resolveModelSlug( export function resolveModelSlugForProvider( provider: ProviderKind, model: string | null | undefined, -): ModelSlug { +): string { return resolveModelSlug(model, provider); } @@ -110,6 +162,33 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +/** + * Resolve the actual API model identifier from a model selection. + * + * Provider-aware: each provider can map `contextWindow` (or other options) + * to whatever the API requires — a model-id suffix, a separate parameter, etc. + * The canonical slug stored in the selection stays unchanged so the + * capabilities system keeps working. + * + * Expects `contextWindow` to already be resolved (via `resolveContextWindow`) + * to the effective value, not stripped to `undefined` for defaults. + */ +export function resolveApiModelId(modelSelection: ModelSelection): string { + switch (modelSelection.provider) { + case "claudeAgent": { + switch (modelSelection.options?.contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } + } + default: { + return modelSelection.model; + } + } +} + export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeCodeEffort | null | undefined, From 648f0676bde993ae64e57d2e2ccd2e797803de32 Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Fri, 27 Mar 2026 05:46:43 +0100 Subject: [PATCH 03/45] Add VS Code Insiders and VSCodium to Open In editor picker (#1392) --- apps/server/src/open.test.ts | 41 +++- apps/server/src/open.ts | 8 +- apps/web/src/components/ChatView.browser.tsx | 179 ++++++++++++++++-- apps/web/src/components/chat/OpenInPicker.tsx | 10 + packages/contracts/src/editor.ts | 17 +- 5 files changed, 229 insertions(+), 26 deletions(-) diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 649e88462c..947a60ac2a 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -40,6 +40,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const vscodeInsidersLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLaunch, { + command: "code-insiders", + args: ["/tmp/workspace"], + }); + + const vscodiumLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLaunch, { + command: "codium", + args: ["/tmp/workspace"], + }); + const zedLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "zed" }, "darwin", @@ -80,6 +98,24 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const vscodeInsidersLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode-insiders" }, + "darwin", + ); + assert.deepEqual(vscodeInsidersLineAndColumn, { + command: "code-insiders", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + + const vscodiumLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscodium" }, + "darwin", + ); + assert.deepEqual(vscodiumLineAndColumn, { + command: "codium", + args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const zedLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, "darwin", @@ -220,13 +256,14 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); - yield* fs.writeFileString(path.join(dir, "cursor.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); const editors = resolveAvailableEditors("win32", { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["cursor", "file-manager"]); + assert.deepEqual(editors, ["vscode-insiders", "vscodium", "file-manager"]); }), ); }); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b2..3fbfd1653d 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -39,10 +39,8 @@ interface CommandAvailabilityOptions { const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { - return ( - (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target) - ); +function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean { + return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target); } function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { @@ -213,7 +211,7 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } if (editorDef.command) { - return shouldUseGotoFlag(editorDef.id, input.cwd) + return shouldUseGotoFlag(editorDef, input.cwd) ? { command: editorDef.command, args: ["--goto", input.cwd] } : { command: editorDef.command, args: [input.cwd] }; } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0995ae1ccc..8abe8c60c2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -356,6 +356,25 @@ function withProjectScripts( }; } +function setDraftThreadWithoutWorktree(): void { + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); +} + function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-plan-target" as MessageId, @@ -1011,30 +1030,162 @@ describe("ChatView timeline estimator parity (full app)", () => { ); it("opens the project cwd for draft threads without a worktree path", async () => { - useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { - projectId: PROJECT_ID, - createdAt: NOW_ISO, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - envMode: "local", + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode"], + }; + }, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode", + }); }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode-insiders"], + }; }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + }); + + try { + const openButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Open", + ) as HTMLButtonElement | null, + "Unable to find Open button.", + ); + openButton.click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscode-insiders", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("filters the open picker menu and opens VSCodium from the menu", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["vscode-insiders", "vscodium"], + }; }, }); + try { + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("VS Code Insiders"), + ) ?? null, + "Unable to find VS Code Insiders menu item.", + ); + + expect( + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).some((item) => + item.textContent?.includes("Zed"), + ), + ).toBe(false); + + const vscodiumItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("VSCodium"), + ) ?? null, + "Unable to find VSCodium menu item.", + ); + (vscodiumItem as HTMLElement).click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "vscodium", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to the first installed editor when the stored favorite is unavailable", async () => { + localStorage.setItem("t3code:last-editor", "vscodium"); + setDraftThreadWithoutWorktree(); + const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { nextFixture.serverConfig = { ...nextFixture.serverConfig, - availableEditors: ["vscode"], + availableEditors: ["vscode-insiders"], }; }, }); @@ -1057,7 +1208,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(openRequest).toMatchObject({ _tag: WS_METHODS.shellOpenInEditor, cwd: "/repo/project", - editor: "vscode", + editor: "vscode-insiders", }); }, { timeout: 8_000, interval: 16 }, diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9f62f7121e..bbe527c28b 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -22,6 +22,16 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray e.id)); From 02989fe600c2aff728b09c5600f75a3a073b3468 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Date: Fri, 27 Mar 2026 04:47:57 +0000 Subject: [PATCH 04/45] fix(web): improve chat header badge and title flex distribution (#1309) --- apps/web/src/components/GitActionsControl.tsx | 6 +++--- apps/web/src/components/ProjectScriptsControl.tsx | 6 +++--- apps/web/src/components/chat/ChatHeader.tsx | 8 ++++---- apps/web/src/components/chat/OpenInPicker.tsx | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 09b84444e2..1593a151da 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -765,7 +765,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } > - + {quickAction.label} @@ -781,12 +781,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions onClick={runQuickAction} > - + {quickAction.label} )} - + { if (open) void invalidateGitQueries(queryClient); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 4d6c5eef78..11b08cc2cf 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -277,11 +277,11 @@ export default function ProjectScriptsControl({ title={`Run ${primaryScript.name}`} > - + {primaryScript.name} - + } @@ -342,7 +342,7 @@ export default function ProjectScriptsControl({ ) : ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 39a4f6eedc..f04c9879fa 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -62,7 +62,7 @@ export const ChatHeader = memo(function ChatHeader({ onToggleDiff, }: ChatHeaderProps) { return ( -
+

{activeProjectName && ( - - {activeProjectName} + + {activeProjectName} )} {activeProjectName && !isGitRepo && ( @@ -82,7 +82,7 @@ export const ChatHeader = memo(function ChatHeader({ )}

-
+
{activeProjectScripts && ( openInEditor(preferredEditor)} > {primaryOption?.Icon &&
@@ -1349,7 +1280,7 @@ export default function Sidebar() { /> } showOnHover - className="top-1 right-1 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" + className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" onClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -1658,202 +1589,199 @@ export default function Sidebar() { )} - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
- - Projects - -
- { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }} - onThreadSortOrderChange={(sortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }} - /> - - + ) : ( + <> + + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( + + + + Intel build on Apple Silicon + {arm64IntelBuildWarningDescription} + {desktopUpdateButtonAction !== "none" ? ( + + + + ) : null} + + + ) : null} + +
+ + Projects + +
+ { + updateSettings({ sidebarProjectSortOrder: sortOrder }); + }} + onThreadSortOrderChange={(sortOrder) => { + updateSettings({ sidebarThreadSortOrder: sortOrder }); + }} + /> + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
+
+ + {shouldShowProjectPathEntry && ( +
+ {isElectron && ( + )} +
+ { + setNewCwd(event.target.value); + setAddProjectError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") handleAddProject(); + if (event.key === "Escape") { + setAddingProject(false); + setAddProjectError(null); + } + }} + autoFocus /> - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
-
+ +
+ {addProjectError && ( +

+ {addProjectError} +

+ )} +
+ +
+
+ )} - {shouldShowProjectPathEntry && ( -
- {isElectron && ( - + + project.id)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + {renderProjectItem(project, null)} + + ))} + )} -
- { - setNewCwd(event.target.value); - setAddProjectError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }} - autoFocus - /> - -
- {addProjectError && ( -

- {addProjectError} -

+ + {projects.length === 0 && !shouldShowProjectPathEntry && ( +
+ No projects yet +
)} -
- -
-
- )} - - {isManualProjectSorting ? ( - - - project.id)} - strategy={verticalListSortingStrategy} +
+
+ + + + + + void navigate({ to: "/settings" })} > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} - - ))} - - - - ) : ( - - {sortedProjects.map((project) => ( - - {renderProjectItem(project, null)} - - ))} + + Settings + + - )} - - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
- No projects yet -
- )} - - - - - - - - {isOnSettings ? ( - window.history.back()} - > - - Back - - ) : ( - void navigate({ to: "/settings" })} - > - - Settings - - )} - - - +
+ + )} ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx new file mode 100644 index 0000000000..bb149c00a4 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -0,0 +1,1390 @@ +import { + ArchiveIcon, + ArchiveX, + ChevronDownIcon, + InfoIcon, + LoaderIcon, + PlusIcon, + RefreshCwIcon, + Undo2Icon, + XIcon, +} from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + PROVIDER_DISPLAY_NAMES, + type ProviderKind, + type ServerProvider, + type ServerProviderModel, + ThreadId, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { normalizeModelSlug } from "@t3tools/shared/model"; +import { Equal } from "effect"; +import { APP_VERSION } from "../../branding"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; +import { useTheme } from "../../hooks/useTheme"; +import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { useThreadActions } from "../../hooks/useThreadActions"; +import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; +import { + MAX_CUSTOM_MODEL_LENGTH, + getCustomModelOptionsByProvider, + resolveAppModelSelectionState, +} from "../../modelSelection"; +import { ensureNativeApi, readNativeApi } from "../../nativeApi"; +import { useStore } from "../../store"; +import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent } from "../ui/collapsible"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ProjectFavicon } from "../ProjectFavicon"; + +const THEME_OPTIONS = [ + { + value: "system", + label: "System", + }, + { + value: "light", + label: "Light", + }, + { + value: "dark", + label: "Dark", + }, +] as const; + +const TIMESTAMP_FORMAT_LABELS = { + locale: "System default", + "12-hour": "12-hour", + "24-hour": "24-hour", +} as const; + +const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; + +type InstallProviderSettings = { + provider: ProviderKind; + title: string; + binaryPlaceholder: string; + binaryDescription: ReactNode; + homePathKey?: "codexHomePath"; + homePlaceholder?: string; + homeDescription?: ReactNode; +}; + +const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ + { + provider: "codex", + title: "Codex", + binaryPlaceholder: "Codex binary path", + binaryDescription: "Path to the Codex binary", + homePathKey: "codexHomePath", + homePlaceholder: "CODEX_HOME", + homeDescription: "Optional custom Codex home and config directory.", + }, + { + provider: "claudeAgent", + title: "Claude", + binaryPlaceholder: "Claude binary path", + binaryDescription: "Path to the Claude binary", + }, +] as const; + +const PROVIDER_STATUS_STYLES = { + disabled: { + dot: "bg-amber-400", + }, + error: { + dot: "bg-destructive", + }, + ready: { + dot: "bg-success", + }, + warning: { + dot: "bg-warning", + }, +} as const; + +function getProviderSummary(provider: ServerProvider | undefined) { + if (!provider) { + return { + headline: "Checking provider status", + detail: "Waiting for the server to report installation and authentication details.", + }; + } + if (!provider.enabled) { + return { + headline: "Disabled", + detail: + provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", + }; + } + if (!provider.installed) { + return { + headline: "Not found", + detail: provider.message ?? "CLI not detected on PATH.", + }; + } + if (provider.authStatus === "authenticated") { + return { + headline: "Authenticated", + detail: provider.message ?? null, + }; + } + if (provider.authStatus === "unauthenticated") { + return { + headline: "Not authenticated", + detail: provider.message ?? null, + }; + } + if (provider.status === "warning") { + return { + headline: "Needs attention", + detail: + provider.message ?? "The provider is installed, but the server could not fully verify it.", + }; + } + if (provider.status === "error") { + return { + headline: "Unavailable", + detail: provider.message ?? "The provider failed its startup checks.", + }; + } + return { + headline: "Available", + detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + }; +} + +function getProviderVersionLabel(version: string | null | undefined) { + if (!version) return null; + return version.startsWith("v") ? version : `v${version}`; +} + +function useRelativeTimeTick(intervalMs = 1_000) { + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return tick; +} + +function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { + useRelativeTimeTick(); + const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; + + if (!lastCheckedRelative) { + return null; + } + + return ( + + {lastCheckedRelative.suffix ? ( + <> + Checked {lastCheckedRelative.value}{" "} + {lastCheckedRelative.suffix} + + ) : ( + <>Checked {lastCheckedRelative.value} + )} + + ); +} + +function SettingsSection({ + title, + icon, + headerAction, + children, +}: { + title: string; + icon?: ReactNode; + headerAction?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

+ {icon} + {title} +

+ {headerAction} +
+
+ {children} +
+
+ ); +} + +function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, +}: { + title: string; + description: string; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; +}) { + return ( +
+
+
+
+

{title}

+ + {resetAction} + +
+

{description}

+ {status ?
{status}
: null} +
+ {control ? ( +
+ {control} +
+ ) : null} +
+ {children} +
+ ); +} + +function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + +function SettingsPageContainer({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +export function useSettingsRestore(onRestored?: () => void) { + const { theme, setTheme } = useTheme(); + const settings = useSettings(); + const { resetSettings } = useUpdateSettings(); + + const isGitWritingModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { + const currentSettings = settings.providers[providerSettings.provider]; + const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + return !Equal.equals(currentSettings, defaultSettings); + }); + + const changedSettingLabels = useMemo( + () => [ + ...(theme !== "system" ? ["Theme"] : []), + ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat + ? ["Time format"] + : []), + ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap + ? ["Diff line wrapping"] + : []), + ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming + ? ["Assistant output"] + : []), + ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode + ? ["New thread mode"] + : []), + ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive + ? ["Archive confirmation"] + : []), + ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete + ? ["Delete confirmation"] + : []), + ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(areProviderSettingsDirty ? ["Providers"] : []), + ], + [ + areProviderSettingsDirty, + isGitWritingModelDirty, + settings.confirmThreadArchive, + settings.confirmThreadDelete, + settings.defaultThreadEnvMode, + settings.diffWordWrap, + settings.enableAssistantStreaming, + settings.timestampFormat, + theme, + ], + ); + + const restoreDefaults = useCallback(async () => { + if (changedSettingLabels.length === 0) return; + const api = readNativeApi(); + const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( + ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( + "\n", + ), + ); + if (!confirmed) return; + + setTheme("system"); + resetSettings(); + onRestored?.(); + }, [changedSettingLabels, onRestored, resetSettings, setTheme]); + + return { + changedSettingLabels, + restoreDefaults, + }; +} + +export function GeneralSettingsPanel() { + const { theme, setTheme } = useTheme(); + const settings = useSettings(); + const { updateSettings } = useUpdateSettings(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); + const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [openProviderDetails, setOpenProviderDetails] = useState>({ + codex: Boolean( + settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || + settings.providers.codex.customModels.length > 0, + ), + claudeAgent: Boolean( + settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || + settings.providers.claudeAgent.customModels.length > 0, + ), + }); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeAgent: "", + }); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const refreshingRef = useRef(false); + const queryClient = useQueryClient(); + const modelListRefs = useRef>>({}); + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + void ensureNativeApi() + .server.refreshProviders() + .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) + .catch((error: unknown) => { + console.warn("Failed to refresh providers", error); + }) + .finally(() => { + refreshingRef.current = false; + setIsRefreshingProviders(false); + }); + }, [queryClient]); + + const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const availableEditors = serverConfigQuery.data?.availableEditors; + const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; + const codexHomePath = settings.providers.codex.homePath; + + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenProvider = textGenerationModelSelection.provider; + const textGenModel = textGenerationModelSelection.model; + const textGenModelOptions = textGenerationModelSelection.options; + const gitModelOptionsByProvider = getCustomModelOptionsByProvider( + settings, + serverProviders, + textGenProvider, + textGenModel, + ); + const isGitWritingModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + + const openKeybindingsFile = useCallback(() => { + if (!keybindingsConfigPath) return; + setOpenKeybindingsError(null); + setIsOpeningKeybindings(true); + const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + if (!editor) { + setOpenKeybindingsError("No available editors found."); + setIsOpeningKeybindings(false); + return; + } + void ensureNativeApi() + .shell.openInEditor(keybindingsConfigPath, editor) + .catch((error) => { + setOpenKeybindingsError( + error instanceof Error ? error.message : "Unable to open keybindings file.", + ); + }) + .finally(() => { + setIsOpeningKeybindings(false); + }); + }, [availableEditors, keybindingsConfigPath]); + + const addCustomModel = useCallback( + (provider: ProviderKind) => { + const customModelInput = customModelInputByProvider[provider]; + const customModels = settings.providers[provider].customModels; + const normalized = normalizeModelSlug(customModelInput, provider); + if (!normalized) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "Enter a model slug.", + })); + return; + } + if ( + serverProviders + .find((candidate) => candidate.provider === provider) + ?.models.some((option) => !option.isCustom && option.slug === normalized) + ) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That model is already built in.", + })); + return; + } + if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + })); + return; + } + if (customModels.includes(normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That custom model is already saved.", + })); + return; + } + + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: [...customModels, normalized], + }, + }, + }); + setCustomModelInputByProvider((existing) => ({ + ...existing, + [provider]: "", + })); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + + const el = modelListRefs.current[provider]; + if (!el) return; + const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + requestAnimationFrame(scrollToEnd); + const observer = new MutationObserver(() => { + scrollToEnd(); + observer.disconnect(); + }); + observer.observe(el, { childList: true, subtree: true }); + setTimeout(() => observer.disconnect(), 2_000); + }, + [customModelInputByProvider, serverProviders, settings, updateSettings], + ); + + const removeCustomModel = useCallback( + (provider: ProviderKind, slug: string) => { + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: settings.providers[provider].customModels.filter( + (model) => model !== slug, + ), + }, + }, + }); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + }, + [settings, updateSettings], + ); + + const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { + const liveProvider = serverProviders.find( + (candidate) => candidate.provider === providerSettings.provider, + ); + const providerConfig = settings.providers[providerSettings.provider]; + const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); + const summary = getProviderSummary(liveProvider); + const models: ReadonlyArray = + liveProvider?.models ?? + providerConfig.customModels.map((slug) => ({ + slug, + name: slug, + isCustom: true, + capabilities: null, + })); + + return { + provider: providerSettings.provider, + title: providerSettings.title, + binaryPlaceholder: providerSettings.binaryPlaceholder, + binaryDescription: providerSettings.binaryDescription, + homePathKey: providerSettings.homePathKey, + homePlaceholder: providerSettings.homePlaceholder, + homeDescription: providerSettings.homeDescription, + binaryPathValue: providerConfig.binaryPath, + isDirty: !Equal.equals(providerConfig, defaultProviderConfig), + liveProvider, + models, + providerConfig, + statusStyle: PROVIDER_STATUS_STYLES[statusKey], + summary, + versionLabel: getProviderVersionLabel(liveProvider?.version), + }; + }); + + const lastCheckedAt = + serverProviders.length > 0 + ? serverProviders.reduce( + (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), + serverProviders[0]!.checkedAt, + ) + : null; + return ( + + + setTheme("system")} /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, + }) + } + /> + ) : null + } + control={ + updateSettings({ diffWordWrap: Boolean(checked) })} + aria-label="Wrap diff lines by default" + /> + } + /> + + + updateSettings({ + enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + }) + } + /> + ) : null + } + control={ + + updateSettings({ enableAssistantStreaming: Boolean(checked) }) + } + aria-label="Stream assistant messages" + /> + } + /> + + + updateSettings({ + defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadArchive: Boolean(checked) }) + } + aria-label="Confirm thread archiving" + /> + } + /> + + + updateSettings({ + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + }) + } + /> + ) : null + } + control={ + + updateSettings({ confirmThreadDelete: Boolean(checked) }) + } + aria-label="Confirm thread deletion" + /> + } + /> + + + updateSettings({ + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { provider, model }, + }, + serverProviders, + ), + }); + }} + /> + provider.provider === textGenProvider) + ?.models ?? [] + } + model={textGenModel} + prompt="" + onPromptChange={() => {}} + modelOptions={textGenModelOptions} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + onModelOptionsChange={(nextOptions) => { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { + provider: textGenProvider, + model: textGenModel, + ...(nextOptions ? { options: nextOptions } : {}), + }, + }, + serverProviders, + ), + }); + }} + /> +
+ } + /> +
+ + + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + +
+ } + > + {providerCards.map((providerCard) => { + const customModelInput = customModelInputByProvider[providerCard.provider]; + const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; + const providerDisplayName = + PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; + + return ( +
+
+
+
+
+ +

{providerDisplayName}

+ {providerCard.versionLabel ? ( + + {providerCard.versionLabel} + + ) : null} + + {providerCard.isDirty ? ( + { + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: + DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], + }, + }); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + }} + /> + ) : null} + +
+

+ {providerCard.summary.headline} + {providerCard.summary.detail ? ` - ${providerCard.summary.detail}` : null} +

+
+
+ + { + const isDisabling = !checked; + const shouldClearModelSelection = + isDisabling && textGenProvider === providerCard.provider; + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + enabled: Boolean(checked), + }, + }, + ...(shouldClearModelSelection + ? { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + } + : {}), + }); + }} + aria-label={`Enable ${providerDisplayName}`} + /> +
+
+
+ + + setOpenProviderDetails((existing) => ({ + ...existing, + [providerCard.provider]: open, + })) + } + > + +
+
+ +
+ + {providerCard.homePathKey ? ( +
+ +
+ ) : null} + +
+
Models
+
+ {providerCard.models.length} model + {providerCard.models.length === 1 ? "" : "s"} available. +
+
{ + modelListRefs.current[providerCard.provider] = el; + }} + className="mt-2 max-h-40 overflow-y-auto pb-1" + > + {providerCard.models.map((model) => { + const caps = model.capabilities; + const capLabels: string[] = []; + if (caps?.supportsFastMode) capLabels.push("Fast mode"); + if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); + if ( + caps?.reasoningEffortLevels && + caps.reasoningEffortLevels.length > 0 + ) { + capLabels.push("Reasoning"); + } + const hasDetails = capLabels.length > 0 || model.name !== model.slug; + + return ( +
+ + {model.name} + + {hasDetails ? ( + + + } + > + + + +
+ + {model.slug} + + {capLabels.length > 0 ? ( +
+ {capLabels.map((label) => ( + + {label} + + ))} +
+ ) : null} +
+
+
+ ) : null} + {model.isCustom ? ( +
+ custom + +
+ ) : null} +
+ ); + })} +
+ +
+ { + const value = event.target.value; + setCustomModelInputByProvider((existing) => ({ + ...existing, + [providerCard.provider]: value, + })); + if (customModelError) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(providerCard.provider); + }} + placeholder={ + providerCard.provider === "codex" + ? "gpt-6.7-codex-ultra-preview" + : "claude-sonnet-5-0" + } + spellCheck={false} + /> + +
+ + {customModelError ? ( +

{customModelError}

+ ) : null} +
+
+
+
+
+ ); + })} + + + + + + {keybindingsConfigPath ?? "Resolving keybindings path..."} + + {openKeybindingsError ? ( + {openKeybindingsError} + ) : ( + Opens in your preferred editor. + )} + + } + control={ + + } + /> + + {APP_VERSION}} + /> + + + ); +} + +export function ArchivedThreadsPanel() { + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); + const archivedGroups = useMemo(() => { + const projectById = new Map(projects.map((project) => [project.id, project] as const)); + return [...projectById.values()] + .map((project) => ({ + project, + threads: threads + .filter((thread) => thread.projectId === project.id && thread.archivedAt !== null) + .toSorted((left, right) => { + const leftKey = left.archivedAt ?? left.createdAt; + const rightKey = right.archivedAt ?? right.createdAt; + return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id); + }), + })) + .filter((group) => group.threads.length > 0); + }, [projects, threads]); + + const handleArchivedThreadContextMenu = useCallback( + async (threadId: ThreadId, position: { x: number; y: number }) => { + const api = readNativeApi(); + if (!api) return; + const clicked = await api.contextMenu.show( + [ + { id: "unarchive", label: "Unarchive" }, + { id: "delete", label: "Delete", destructive: true }, + ], + position, + ); + + if (clicked === "unarchive") { + try { + await unarchiveThread(threadId); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to unarchive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } + + if (clicked === "delete") { + await confirmAndDeleteThread(threadId); + } + }, + [confirmAndDeleteThread, unarchiveThread], + ); + + return ( + + {archivedGroups.length === 0 ? ( + + + + + + + No archived threads + Archived threads will appear here. + + + + ) : ( + archivedGroups.map(({ project, threads: projectThreads }) => ( + } + > + {projectThreads.map((thread) => ( +
{ + event.preventDefault(); + void handleArchivedThreadContextMenu(thread.id, { + x: event.clientX, + y: event.clientY, + }); + }} + > +
+

{thread.title}

+

+ Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} + {" \u00b7 Created "} + {formatRelativeTimeLabel(thread.createdAt)} +

+
+ +
+ ))} +
+ )) + )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx new file mode 100644 index 0000000000..ffca1e2092 --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -0,0 +1,82 @@ +import type { ComponentType } from "react"; +import { ArchiveIcon, ArrowLeftIcon, Settings2Icon } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; + +import { + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, +} from "../ui/sidebar"; + +export type SettingsSectionPath = "/settings/general" | "/settings/archived"; + +export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ + label: string; + to: SettingsSectionPath; + icon: ComponentType<{ className?: string }>; +}> = [ + { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon }, +]; + +export function SettingsSidebarNav({ pathname }: { pathname: string }) { + const navigate = useNavigate(); + + return ( + <> + + + + {SETTINGS_NAV_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = pathname === item.to; + return ( + + void navigate({ to: item.to, replace: true })} + > + + {item.label} + + + ); + })} + + + + + + + + + window.history.back()} + > + + Back + + + + + + ); +} diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 63cdef8481..9fd1a12956 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -44,10 +44,16 @@ export function showContextMenuFallback( btn.type = "button"; btn.textContent = item.label; const isDestructiveAction = item.destructive === true || item.id === "delete"; - btn.className = isDestructiveAction - ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" - : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; - btn.addEventListener("click", () => cleanup(item.id)); + const isDisabled = item.disabled === true; + btn.disabled = isDisabled; + btn.className = isDisabled + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-muted-foreground/60 cursor-not-allowed" + : isDestructiveAction + ? "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-destructive hover:bg-accent cursor-default" + : "flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-popover-foreground hover:bg-accent cursor-default"; + if (!isDisabled) { + btn.addEventListener("click", () => cleanup(item.id)); + } menu.appendChild(btn); } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts new file mode 100644 index 0000000000..832ee17f7f --- /dev/null +++ b/apps/web/src/hooks/useSettings.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { buildLegacyClientSettingsMigrationPatch } from "./useSettings"; + +describe("buildLegacyClientSettingsMigrationPatch", () => { + it("migrates archive confirmation from legacy local settings", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + confirmThreadArchive: true, + confirmThreadDelete: false, + }), + ).toEqual({ + confirmThreadArchive: true, + confirmThreadDelete: false, + }); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index addf550e38..3f804bc48b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -202,6 +202,10 @@ export function buildLegacyClientSettingsMigrationPatch( ): Partial> { const patch: Partial> = {}; + if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { + patch.confirmThreadArchive = legacySettings.confirmThreadArchive; + } + if (Predicate.isBoolean(legacySettings.confirmThreadDelete)) { patch.confirmThreadDelete = legacySettings.confirmThreadDelete; } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts new file mode 100644 index 0000000000..83cfe911fc --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.ts @@ -0,0 +1,211 @@ +import { ThreadId } from "@t3tools/contracts"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useHandleNewThread } from "./useHandleNewThread"; +import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { newCommandId } from "../lib/utils"; +import { readNativeApi } from "../nativeApi"; +import { useStore } from "../store"; +import { useTerminalStateStore } from "../terminalStateStore"; +import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; +import { toastManager } from "../components/ui/toast"; +import { useSettings } from "./useSettings"; + +export function useThreadActions() { + const threads = useStore((store) => store.threads); + const projects = useStore((store) => store.projects); + const appSettings = useSettings(); + const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); + const clearProjectDraftThreadById = useComposerDraftStore( + (store) => store.clearProjectDraftThreadById, + ); + const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); + const routeThreadId = useParams({ + strict: false, + select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + }); + const navigate = useNavigate(); + const { handleNewThread } = useHandleNewThread(); + const queryClient = useQueryClient(); + const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); + + const archiveThread = useCallback( + async (threadId: ThreadId) => { + const api = readNativeApi(); + if (!api) return; + const thread = threads.find((entry) => entry.id === threadId); + if (!thread) return; + if (thread.session?.status === "running" && thread.session.activeTurnId != null) { + throw new Error("Cannot archive a running thread."); + } + + await api.orchestration.dispatchCommand({ + type: "thread.archive", + commandId: newCommandId(), + threadId, + }); + + if (routeThreadId === threadId) { + await handleNewThread(thread.projectId); + } + }, + [handleNewThread, routeThreadId, threads], + ); + + const unarchiveThread = useCallback(async (threadId: ThreadId) => { + const api = readNativeApi(); + if (!api) return; + await api.orchestration.dispatchCommand({ + type: "thread.unarchive", + commandId: newCommandId(), + threadId, + }); + }, []); + + const deleteThread = useCallback( + async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { + const api = readNativeApi(); + if (!api) return; + const thread = threads.find((entry) => entry.id === threadId); + if (!thread) return; + const threadProject = projects.find((project) => project.id === thread.projectId); + const deletedIds = opts.deletedThreadIds; + const survivingThreads = + deletedIds && deletedIds.size > 0 + ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id)) + : threads; + const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); + const displayWorktreePath = orphanedWorktreePath + ? formatWorktreePathForDisplay(orphanedWorktreePath) + : null; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const shouldDeleteWorktree = + canDeleteWorktree && + (await api.dialogs.confirm( + [ + "This thread is the only one linked to this worktree:", + displayWorktreePath ?? orphanedWorktreePath, + "", + "Delete the worktree too?", + ].join("\n"), + )); + + if (thread.session && thread.session.status !== "closed") { + await api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + + try { + await api.terminal.close({ threadId, deleteHistory: true }); + } catch { + // Terminal may already be closed. + } + + const deletedThreadIds = opts.deletedThreadIds ?? new Set(); + const shouldNavigateToFallback = routeThreadId === threadId; + const fallbackThreadId = getFallbackThreadIdAfterDelete({ + threads, + deletedThreadId: threadId, + deletedThreadIds, + sortOrder: appSettings.sidebarThreadSortOrder, + }); + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: newCommandId(), + threadId, + }); + clearComposerDraftForThread(threadId); + clearProjectDraftThreadById(thread.projectId, thread.id); + clearTerminalState(threadId); + + if (shouldNavigateToFallback) { + if (fallbackThreadId) { + await navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId }, + replace: true, + }); + } else { + await navigate({ to: "/", replace: true }); + } + } + + if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { + return; + } + + try { + await removeWorktreeMutation.mutateAsync({ + cwd: threadProject.cwd, + path: orphanedWorktreePath, + force: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error removing worktree."; + console.error("Failed to remove orphaned worktree after thread deletion", { + threadId, + projectCwd: threadProject.cwd, + worktreePath: orphanedWorktreePath, + error, + }); + toastManager.add({ + type: "error", + title: "Thread deleted, but worktree removal failed", + description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, + }); + } + }, + [ + clearComposerDraftForThread, + clearProjectDraftThreadById, + clearTerminalState, + appSettings.sidebarThreadSortOrder, + navigate, + projects, + removeWorktreeMutation, + routeThreadId, + threads, + ], + ); + + const confirmAndDeleteThread = useCallback( + async (threadId: ThreadId) => { + const api = readNativeApi(); + if (!api) return; + const thread = threads.find((entry) => entry.id === threadId); + if (!thread) return; + + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } + + await deleteThread(threadId); + }, + [appSettings.confirmThreadDelete, deleteThread, threads], + ); + + return { + archiveThread, + unarchiveThread, + deleteThread, + confirmAndDeleteThread, + }; +} diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index eeb3a03a82..a37c67064a 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -23,25 +23,42 @@ export function createMemoryStorage(): StateStorage { }; } +export function isStateStorage( + storage: Partial | null | undefined, +): storage is StateStorage { + return ( + storage !== null && + storage !== undefined && + typeof storage.getItem === "function" && + typeof storage.setItem === "function" && + typeof storage.removeItem === "function" + ); +} + +export function resolveStorage(storage: Partial | null | undefined): StateStorage { + return isStateStorage(storage) ? storage : createMemoryStorage(); +} + export function createDebouncedStorage( - baseStorage: StateStorage, + baseStorage: Partial | null | undefined, debounceMs: number = 300, ): DebouncedStorage { + const resolvedStorage = resolveStorage(baseStorage); const debouncedSetItem = new Debouncer( (name: string, value: string) => { - baseStorage.setItem(name, value); + resolvedStorage.setItem(name, value); }, { wait: debounceMs }, ); return { - getItem: (name) => baseStorage.getItem(name), + getItem: (name) => resolvedStorage.getItem(name), setItem: (name, value) => { debouncedSetItem.maybeExecute(name, value); }, removeItem: (name) => { debouncedSetItem.cancel(); - baseStorage.removeItem(name); + resolvedStorage.removeItem(name); }, flush: () => { debouncedSetItem.flush(); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 880d5ef64b..77b1b15842 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,11 +9,18 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' -import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' +import { Route as SettingsGeneralRouteImport } from './routes/settings.general' +import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any) const ChatRoute = ChatRouteImport.update({ id: '/_chat', getParentRoute: () => rootRouteImport, @@ -23,10 +30,15 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) -const ChatSettingsRoute = ChatSettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => ChatRoute, +const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ + id: '/general', + path: '/general', + getParentRoute: () => SettingsRoute, +} as any) +const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ + id: '/archived', + path: '/archived', + getParentRoute: () => SettingsRoute, } as any) const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ id: '/$threadId', @@ -36,35 +48,66 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute + '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute - '/settings': typeof ChatSettingsRoute + '/settings/archived': typeof SettingsArchivedRoute + '/settings/general': typeof SettingsGeneralRoute } export interface FileRoutesByTo { + '/settings': typeof SettingsRouteWithChildren '/$threadId': typeof ChatThreadIdRoute - '/settings': typeof ChatSettingsRoute + '/settings/archived': typeof SettingsArchivedRoute + '/settings/general': typeof SettingsGeneralRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren + '/settings': typeof SettingsRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute - '/_chat/settings': typeof ChatSettingsRoute + '/settings/archived': typeof SettingsArchivedRoute + '/settings/general': typeof SettingsGeneralRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$threadId' | '/settings' + fullPaths: + | '/' + | '/settings' + | '/$threadId' + | '/settings/archived' + | '/settings/general' fileRoutesByTo: FileRoutesByTo - to: '/$threadId' | '/settings' | '/' - id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' + to: + | '/settings' + | '/$threadId' + | '/settings/archived' + | '/settings/general' + | '/' + id: + | '__root__' + | '/_chat' + | '/settings' + | '/_chat/$threadId' + | '/settings/archived' + | '/settings/general' + | '/_chat/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren + SettingsRoute: typeof SettingsRouteWithChildren } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRouteImport + } '/_chat': { id: '/_chat' path: '' @@ -79,12 +122,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } - '/_chat/settings': { - id: '/_chat/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof ChatSettingsRouteImport - parentRoute: typeof ChatRoute + '/settings/general': { + id: '/settings/general' + path: '/general' + fullPath: '/settings/general' + preLoaderRoute: typeof SettingsGeneralRouteImport + parentRoute: typeof SettingsRoute + } + '/settings/archived': { + id: '/settings/archived' + path: '/archived' + fullPath: '/settings/archived' + preLoaderRoute: typeof SettingsArchivedRouteImport + parentRoute: typeof SettingsRoute } '/_chat/$threadId': { id: '/_chat/$threadId' @@ -98,20 +148,33 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatThreadIdRoute: typeof ChatThreadIdRoute - ChatSettingsRoute: typeof ChatSettingsRoute ChatIndexRoute: typeof ChatIndexRoute } const ChatRouteChildren: ChatRouteChildren = { ChatThreadIdRoute: ChatThreadIdRoute, - ChatSettingsRoute: ChatSettingsRoute, ChatIndexRoute: ChatIndexRoute, } const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) +interface SettingsRouteChildren { + SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsGeneralRoute: typeof SettingsGeneralRoute +} + +const SettingsRouteChildren: SettingsRouteChildren = { + SettingsArchivedRoute: SettingsArchivedRoute, + SettingsGeneralRoute: SettingsGeneralRoute, +} + +const SettingsRouteWithChildren = SettingsRoute._addFileChildren( + SettingsRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, + SettingsRoute: SettingsRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 5ebed20fba..e99ec50226 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; +import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; @@ -54,7 +55,9 @@ function RootRouteView() { - + + + ); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx deleted file mode 100644 index 3e92891a54..0000000000 --- a/apps/web/src/routes/_chat.settings.tsx +++ /dev/null @@ -1,1313 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - ChevronDownIcon, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - RotateCcwIcon, - Undo2Icon, - XIcon, -} from "lucide-react"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import { - PROVIDER_DISPLAY_NAMES, - type ProviderKind, - type ServerProvider, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; -import { useSettings, useUpdateSettings } from "../hooks/useSettings"; -import { - getCustomModelOptionsByProvider, - MAX_CUSTOM_MODEL_LENGTH, - resolveAppModelSelectionState, -} from "../modelSelection"; -import { APP_VERSION } from "../branding"; -import { Button } from "../components/ui/button"; -import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; -import { Input } from "../components/ui/input"; -import { - Select, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "../components/ui/select"; -import { SidebarTrigger } from "../components/ui/sidebar"; -import { Switch } from "../components/ui/switch"; -import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; -import { TraitsPicker } from "../components/chat/TraitsPicker"; -import { SidebarInset } from "../components/ui/sidebar"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { isElectron } from "../env"; -import { useTheme } from "../hooks/useTheme"; -import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; -import { cn } from "../lib/utils"; -import { formatRelativeTime } from "../timestampFormat"; -import { ensureNativeApi, readNativeApi } from "../nativeApi"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { Equal } from "effect"; - -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - description: "Match your OS appearance setting.", - }, - { - value: "light", - label: "Light", - description: "Always use the light theme.", - }, - { - value: "dark", - label: "Dark", - description: "Always use the dark theme.", - }, -] as const; - -const TIMESTAMP_FORMAT_LABELS = { - locale: "System default", - "12-hour": "12-hour", - "24-hour": "24-hour", -} as const; - -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; - -type InstallProviderSettings = { - provider: ProviderKind; - title: string; - binaryPlaceholder: string; - binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; - homePlaceholder?: string; - homeDescription?: ReactNode; -}; - -const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", - binaryPlaceholder: "Codex binary path", - binaryDescription: "Path to the Codex binary", - homePathKey: "codexHomePath", - homePlaceholder: "CODEX_HOME", - homeDescription: "Optional custom Codex home and config directory.", - }, - { - provider: "claudeAgent", - title: "Claude", - binaryPlaceholder: "Claude binary path", - binaryDescription: "Path to the Claude binary", - }, -]; - -const PROVIDER_STATUS_STYLES = { - disabled: { - dot: "bg-amber-400", - badge: "warning" as const, - }, - error: { - dot: "bg-destructive", - badge: "error" as const, - }, - ready: { - dot: "bg-success", - badge: "success" as const, - }, - warning: { - dot: "bg-warning", - badge: "warning" as const, - }, -} as const; - -function getProviderSummary(provider: ServerProvider | undefined): { - readonly headline: string; - readonly detail: string | null; -} { - if (!provider) { - return { - headline: "Checking provider status", - detail: "Waiting for the server to report installation and authentication details.", - }; - } - if (!provider.enabled) { - return { - headline: "Disabled", - detail: - provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", - }; - } - if (!provider.installed) { - return { - headline: "Not found", - detail: provider.message ?? "CLI not detected on PATH.", - }; - } - if (provider.authStatus === "authenticated") { - return { - headline: "Authenticated", - detail: provider.message ?? null, - }; - } - if (provider.authStatus === "unauthenticated") { - return { - headline: "Not authenticated", - detail: provider.message ?? null, - }; - } - if (provider.status === "warning") { - return { - headline: "Needs attention", - detail: - provider.message ?? "The provider is installed, but the server could not fully verify it.", - }; - } - if (provider.status === "error") { - return { - headline: "Unavailable", - detail: provider.message ?? "The provider failed its startup checks.", - }; - } - return { - headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", - }; -} - -function getProviderVersionLabel(version: string | null | undefined): string | null { - if (!version) return null; - return version.startsWith("v") ? version : `v${version}`; -} - -/** Returns a timestamp that updates on an interval, forcing re-renders to keep relative times fresh. */ -function useRelativeTimeTick(intervalMs = 1_000): number { - const [tick, setTick] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setTick(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return tick; -} - -function SettingsSection({ - title, - headerAction, - children, -}: { - title: string; - headerAction?: ReactNode; - children: ReactNode; -}) { - return ( -
-
-

- {title} -

- {headerAction} -
-
- {children} -
-
- ); -} - -function SettingsRow({ - title, - description, - status, - resetAction, - control, - children, -}: { - title: string; - description: string; - status?: ReactNode; - resetAction?: ReactNode; - control?: ReactNode; - children?: ReactNode; -}) { - return ( -
-
-
-
-

{title}

- - {resetAction} - -
-

{description}

- {status ?
{status}
: null} -
- {control ? ( -
- {control} -
- ) : null} -
- {children} -
- ); -} - -function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - { - event.stopPropagation(); - onClick(); - }} - > - - - } - /> - Reset to default - - ); -} - -function SettingsRouteView() { - const { theme, setTheme } = useTheme(); - const settings = useSettings(); - const { updateSettings, resetSettings } = useUpdateSettings(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openProviderDetails, setOpenProviderDetails] = useState>({ - codex: Boolean( - settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || - settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || - settings.providers.codex.customModels.length > 0, - ), - claudeAgent: Boolean( - settings.providers.claudeAgent.binaryPath !== - DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || - settings.providers.claudeAgent.customModels.length > 0, - ), - }); - const [customModelInputByProvider, setCustomModelInputByProvider] = useState< - Record - >({ - codex: "", - claudeAgent: "", - }); - const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< - Partial> - >({}); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const refreshingRef = useRef(false); - const queryClient = useQueryClient(); - useRelativeTimeTick(); - - const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - const api = ensureNativeApi(); - api.server - .refreshProviders() - .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) - .catch((error: unknown) => { - console.warn("Failed to refresh providers", error); - }) - .finally(() => { - refreshingRef.current = false; - setIsRefreshingProviders(false); - }); - }, [queryClient]); - - const modelListRefs = useRef>>({}); - - const codexHomePath = settings.providers.codex.homePath; - const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; - const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; - - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenProvider = textGenerationModelSelection.provider; - const textGenModel = textGenerationModelSelection.model; - const textGenModelOptions = textGenerationModelSelection.options; - const gitModelOptionsByProvider = getCustomModelOptionsByProvider( - settings, - serverProviders, - textGenProvider, - textGenModel, - ); - const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { - const currentSettings = settings.providers[providerSettings.provider]; - const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; - return !Equal.equals(currentSettings, defaultSettings); - }); - const isGitWritingModelDirty = !Equal.equals( - settings.textGenerationModelSelection ?? null, - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, - ); - const changedSettingLabels = [ - ...(theme !== "system" ? ["Theme"] : []), - ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat - ? ["Time format"] - : []), - ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap - ? ["Diff line wrapping"] - : []), - ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming - ? ["Assistant output"] - : []), - ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode - ? ["New thread mode"] - : []), - ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete - ? ["Delete confirmation"] - : []), - ...(isGitWritingModelDirty ? ["Git writing model"] : []), - ...(areProviderSettingsDirty ? ["Providers"] : []), - ]; - - const openKeybindingsFile = useCallback(() => { - if (!keybindingsConfigPath) return; - setOpenKeybindingsError(null); - setIsOpeningKeybindings(true); - const api = ensureNativeApi(); - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); - if (!editor) { - setOpenKeybindingsError("No available editors found."); - setIsOpeningKeybindings(false); - return; - } - void api.shell - .openInEditor(keybindingsConfigPath, editor) - .catch((error) => { - setOpenKeybindingsError( - error instanceof Error ? error.message : "Unable to open keybindings file.", - ); - }) - .finally(() => { - setIsOpeningKeybindings(false); - }); - }, [availableEditors, keybindingsConfigPath]); - - const addCustomModel = useCallback( - (provider: ProviderKind) => { - const customModelInput = customModelInputByProvider[provider]; - const customModels = settings.providers[provider].customModels; - const normalized = normalizeModelSlug(customModelInput, provider); - if (!normalized) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "Enter a model slug.", - })); - return; - } - if ( - serverProviders - .find((candidate) => candidate.provider === provider) - ?.models.some((option) => !option.isCustom && option.slug === normalized) - ) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That model is already built in.", - })); - return; - } - if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, - })); - return; - } - if (customModels.includes(normalized)) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: "That custom model is already saved.", - })); - return; - } - - updateSettings({ - providers: { - ...settings.providers, - [provider]: { - ...settings.providers[provider], - customModels: [...customModels, normalized], - }, - }, - }); - setCustomModelInputByProvider((existing) => ({ - ...existing, - [provider]: "", - })); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - // Watch for DOM changes (server may push updated model list) and scroll to bottom - const el = modelListRefs.current[provider]; - if (el) { - const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - // Immediate scroll for the optimistic update - requestAnimationFrame(scrollToEnd); - // Also observe mutations for when the server pushes an updated list - const observer = new MutationObserver(() => { - scrollToEnd(); - observer.disconnect(); - }); - observer.observe(el, { childList: true, subtree: true }); - // Clean up observer after a reasonable window - setTimeout(() => observer.disconnect(), 2000); - } - }, - [customModelInputByProvider, serverProviders, settings, updateSettings], - ); - - const removeCustomModel = useCallback( - (provider: ProviderKind, slug: string) => { - const customModels = settings.providers[provider].customModels; - updateSettings({ - providers: { - ...settings.providers, - [provider]: { - ...settings.providers[provider], - customModels: customModels.filter((model) => model !== slug), - }, - }, - }); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, - [settings, updateSettings], - ); - - const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { - const liveProvider = serverProviders.find( - (candidate) => candidate.provider === providerSettings.provider, - ); - const providerConfig = settings.providers[providerSettings.provider]; - const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; - const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); - const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; - const summary = getProviderSummary(liveProvider); - const models: ReadonlyArray = - liveProvider?.models ?? - providerConfig.customModels.map((slug) => ({ - slug, - name: slug, - isCustom: true, - capabilities: null, - })); - const binaryPathValue = providerConfig.binaryPath; - const isDirty = !Equal.equals(providerConfig, defaultProviderConfig); - - return { - provider: providerSettings.provider, - title: providerSettings.title, - binaryPlaceholder: providerSettings.binaryPlaceholder, - binaryDescription: providerSettings.binaryDescription, - homePathKey: providerSettings.homePathKey, - homePlaceholder: providerSettings.homePlaceholder, - homeDescription: providerSettings.homeDescription, - binaryPathValue, - isDirty, - liveProvider, - models, - providerConfig, - statusKey, - statusStyle, - summary, - versionLabel: getProviderVersionLabel(liveProvider?.version), - }; - }); - - async function restoreDefaults() { - if (changedSettingLabels.length === 0) return; - - const api = readNativeApi(); - const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( - ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( - "\n", - ), - ); - if (!confirmed) return; - - setTheme("system"); - resetSettings(); - setOpenProviderDetails({ - codex: false, - claudeAgent: false, - }); - setCustomModelInputByProvider({ - codex: "", - claudeAgent: "", - }); - setCustomModelErrorByProvider({}); - } - - return ( - -
- {!isElectron && ( -
-
- - Settings -
- -
-
-
- )} - - {isElectron && ( -
- - Settings - -
- -
-
- )} - -
-
- - setTheme("system")} /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - diffWordWrap: Boolean(checked), - }) - } - aria-label="Wrap diff lines by default" - /> - } - /> - - - updateSettings({ - enableAssistantStreaming: - DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> - } - /> - - - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> - } - /> - { - updateSettings({ - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - }} - /> - ) : null - } - control={ -
- { - updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState( - { - ...settings, - textGenerationModelSelection: { provider, model }, - }, - serverProviders, - ), - }); - }} - /> - provider.provider === textGenProvider) - ?.models ?? [] - } - model={textGenModel} - prompt="" - onPromptChange={() => {}} - modelOptions={textGenModelOptions} - allowPromptInjectedEffort={false} - triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" - onModelOptionsChange={(nextOptions) => { - updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState( - { - ...settings, - textGenerationModelSelection: { - provider: textGenProvider, - model: textGenModel, - ...(nextOptions ? { options: nextOptions } : {}), - }, - }, - serverProviders, - ), - }); - }} - /> -
- } - /> -
- - - {serverProviders.length > 0 ? ( - - {(() => { - const rel = formatRelativeTime( - serverProviders.reduce( - (latest, provider) => - provider.checkedAt > latest ? provider.checkedAt : latest, - serverProviders[0]!.checkedAt, - ), - ); - return rel.suffix ? ( - <> - Checked {rel.value}{" "} - {rel.suffix} - - ) : ( - <>Checked {rel.value} - ); - })()} - - ) : null} - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - -
- } - > - {providerCards.map((providerCard) => { - const customModelInput = customModelInputByProvider[providerCard.provider]; - const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; - const providerDisplayName = - PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; - - return ( -
-
-
-
-
- -

- {providerDisplayName} -

- {providerCard.versionLabel ? ( - - {providerCard.versionLabel} - - ) : null} - - {providerCard.isDirty ? ( - { - updateSettings({ - providers: { - ...settings.providers, - [providerCard.provider]: - DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], - }, - }); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [providerCard.provider]: null, - })); - }} - /> - ) : null} - -
-

- {providerCard.summary.headline} - {providerCard.summary.detail - ? ` — ${providerCard.summary.detail}` - : null} -

-
-
- - { - const isDisabling = !checked; - // The resolved provider accounts for both explicit - // selection and the implicit default (codex). - const resolvedProvider = textGenProvider; - // When disabling the provider that's currently used for - // text generation, clear the selection so it falls back to - // the next available provider's default model. - const shouldClearModelSelection = - isDisabling && resolvedProvider === providerCard.provider; - updateSettings({ - providers: { - ...settings.providers, - [providerCard.provider]: { - ...settings.providers[providerCard.provider], - enabled: Boolean(checked), - }, - }, - ...(shouldClearModelSelection - ? { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - } - : {}), - }); - }} - aria-label={`Enable ${providerDisplayName}`} - /> -
-
-
- - - setOpenProviderDetails((existing) => ({ - ...existing, - [providerCard.provider]: open, - })) - } - > - -
- {/* Binary path */} -
- -
- - {/* Home path (Codex only) */} - {providerCard.homePathKey ? ( -
- -
- ) : null} - - {/* Models */} -
-
Models
-
- {providerCard.models.length} model - {providerCard.models.length === 1 ? "" : "s"} available. -
-
{ - modelListRefs.current[providerCard.provider] = el; - }} - className="mt-2 max-h-40 overflow-y-auto pb-1" - > - {providerCard.models.map((model) => { - const caps = model.capabilities; - const capLabels: string[] = []; - if (caps?.supportsFastMode) capLabels.push("Fast mode"); - if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); - if ( - caps?.reasoningEffortLevels && - caps.reasoningEffortLevels.length > 0 - ) - capLabels.push("Reasoning"); - const hasDetails = - capLabels.length > 0 || model.name !== model.slug; - - return ( -
- - {model.name} - - {hasDetails ? ( - - - } - > - - - -
- - {model.slug} - - {capLabels.length > 0 ? ( -
- {capLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
-
- ) : null} - {model.isCustom ? ( -
- - custom - - -
- ) : null} -
- ); - })} -
-
- { - const value = event.target.value; - setCustomModelInputByProvider((existing) => ({ - ...existing, - [providerCard.provider]: value, - })); - if (customModelError) { - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [providerCard.provider]: null, - })); - } - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addCustomModel(providerCard.provider); - }} - placeholder={ - providerCard.provider === "codex" - ? "gpt-6.7-codex-ultra-preview" - : "claude-sonnet-5-0" - } - spellCheck={false} - /> - -
- {customModelError ? ( -

{customModelError}

- ) : null} -
-
-
-
-
- ); - })} - - - - - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - {openKeybindingsError} - ) : ( - Opens in your preferred editor. - )} - - } - control={ - - } - /> - - {APP_VERSION} - } - /> - -
-
-
- - ); -} - -export const Route = createFileRoute("/_chat/settings")({ - component: SettingsRouteView, -}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 80525f556e..3c86ab42f3 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,9 +1,8 @@ import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; -import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; -import ThreadSidebar from "../components/Sidebar"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isTerminalFocused } from "../lib/terminalFocus"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -12,12 +11,8 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useSettings } from "~/hooks/useSettings"; -import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; -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; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -94,43 +89,11 @@ function ChatRouteGlobalShortcuts() { } function ChatRouteLayout() { - const navigate = useNavigate(); - - useEffect(() => { - const onMenuAction = window.desktopBridge?.onMenuAction; - if (typeof onMenuAction !== "function") { - return; - } - - const unsubscribe = onMenuAction((action) => { - if (action !== "open-settings") return; - void navigate({ to: "/settings" }); - }); - - return () => { - unsubscribe?.(); - }; - }, [navigate]); - return ( - + <> - - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - }} - > - - - - + ); } diff --git a/apps/web/src/routes/settings.archived.tsx b/apps/web/src/routes/settings.archived.tsx new file mode 100644 index 0000000000..3ad690afc0 --- /dev/null +++ b/apps/web/src/routes/settings.archived.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ArchivedThreadsPanel } from "../components/settings/SettingsPanels"; + +export const Route = createFileRoute("/settings/archived")({ + component: ArchivedThreadsPanel, +}); diff --git a/apps/web/src/routes/settings.general.tsx b/apps/web/src/routes/settings.general.tsx new file mode 100644 index 0000000000..7fb503e0a2 --- /dev/null +++ b/apps/web/src/routes/settings.general.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { GeneralSettingsPanel } from "../components/settings/SettingsPanels"; + +export const Route = createFileRoute("/settings/general")({ + component: GeneralSettingsPanel, +}); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx new file mode 100644 index 0000000000..edee426732 --- /dev/null +++ b/apps/web/src/routes/settings.tsx @@ -0,0 +1,77 @@ +import { RotateCcwIcon } from "lucide-react"; +import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { useState } from "react"; + +import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { Button } from "../components/ui/button"; +import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { isElectron } from "../env"; + +function SettingsContentLayout() { + const [restoreSignal, setRestoreSignal] = useState(0); + const { changedSettingLabels, restoreDefaults } = useSettingsRestore(() => + setRestoreSignal((value) => value + 1), + ); + + return ( + +
+ {!isElectron && ( +
+
+ + Settings +
+ +
+
+
+ )} + + {isElectron && ( +
+ + Settings + +
+ +
+
+ )} + +
+ +
+
+
+ ); +} + +function SettingsRouteLayout() { + return ; +} + +export const Route = createFileRoute("/settings")({ + beforeLoad: ({ location }) => { + if (location.pathname === "/settings") { + throw redirect({ to: "/settings/general", replace: true }); + } + }, + component: SettingsRouteLayout, +}); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 4da4f23c8c..db62bad523 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -29,6 +29,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", + archivedAt: null, latestTurn: null, branch: null, worktreePath: null, @@ -72,6 +73,7 @@ function makeReadModelThread(overrides: Partial { expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); }); + it("maps archivedAt from the read model", () => { + const initialState = makeState(makeThread()); + const archivedAt = "2026-02-28T00:00:00.000Z"; + const next = syncServerReadModel( + initialState, + makeReadModel( + makeReadModelThread({ + archivedAt, + }), + ), + ); + + expect(next.threads[0]?.archivedAt).toBe(archivedAt); + }); + it("preserves the current project order when syncing incoming read model updates", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4590b2886d..a5beb5b1bf 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -299,6 +299,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea })), error: thread.session?.lastError ?? null, createdAt: thread.createdAt, + archivedAt: thread.archivedAt, updatedAt: thread.updatedAt, latestTurn: thread.latestTurn, lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e7e240cf25..d618275682 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -7,9 +7,7 @@ const THREAD_ID = ThreadId.makeUnsafe("thread-1"); describe("terminalStateStore actions", () => { beforeEach(() => { - if (typeof localStorage !== "undefined") { - localStorage.clear(); - } + useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ terminalStateByThreadId: {} }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index b2cea6d560..4f51e2ed8d 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -8,6 +8,7 @@ import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; +import { resolveStorage } from "./lib/storage"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -27,6 +28,10 @@ interface ThreadTerminalState { const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +function createTerminalStateStorage() { + return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); +} + function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; @@ -542,7 +547,7 @@ export const useTerminalStateStore = create()( { name: TERMINAL_STATE_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(createTerminalStateStorage), partialize: (state) => ({ terminalStateByThreadId: state.terminalStateByThreadId, }), diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index a75ab21019..453c070166 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -66,3 +66,8 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st const days = Math.floor(hours / 24); return { value: `${days}d`, suffix: "ago" }; } + +export function formatRelativeTimeLabel(isoDate: string) { + const relative = formatRelativeTime(isoDate); + return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index de06f95538..e6cb1efea6 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -101,6 +101,7 @@ export interface Thread { proposedPlans: ProposedPlan[]; error: string | null; createdAt: string; + archivedAt: string | null; updatedAt?: string | undefined; latestTurn: OrchestrationLatestTurn | null; lastVisitedAt?: string | undefined; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 574675ae11..723661ccbb 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -23,6 +23,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", + archivedAt: null, latestTurn: null, branch: null, worktreePath: null, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d282ae721..0443128dd5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -57,6 +57,7 @@ export interface ContextMenuItem { id: T; label: string; destructive?: boolean; + disabled?: boolean; } export type DesktopUpdateStatus = diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 19cef5a392..59c023e62a 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -5,6 +5,8 @@ import { Effect, Schema } from "effect"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + OrchestrationCommand, + OrchestrationEvent, OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, ProjectCreatedPayload, @@ -32,6 +34,8 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); +const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); +const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => @@ -227,6 +231,66 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () => }), ); +it.effect("decodes thread archive and unarchive commands", () => + Effect.gen(function* () { + const archive = yield* decodeOrchestrationCommand({ + type: "thread.archive", + commandId: "cmd-archive-1", + threadId: "thread-1", + }); + const unarchive = yield* decodeOrchestrationCommand({ + type: "thread.unarchive", + commandId: "cmd-unarchive-1", + threadId: "thread-1", + }); + + assert.strictEqual(archive.type, "thread.archive"); + assert.strictEqual(unarchive.type, "thread.unarchive"); + }), +); + +it.effect("decodes thread archived and unarchived events", () => + Effect.gen(function* () { + const archived = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-archive-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.archived", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-archive-1", + causationEventId: null, + correlationId: "cmd-archive-1", + metadata: {}, + payload: { + threadId: "thread-1", + archivedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unarchived = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unarchive-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unarchived", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unarchive-1", + causationEventId: null, + correlationId: "cmd-unarchive-1", + metadata: {}, + payload: { + threadId: "thread-1", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(archived.type, "thread.archived"); + assert.strictEqual(archived.payload.archivedAt, "2026-01-01T00:00:00.000Z"); + assert.strictEqual(unarchived.type, "thread.unarchived"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 0b40bb6fdf..31631666e2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -283,6 +283,7 @@ export const OrchestrationThread = Schema.Struct({ latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(() => null)), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(Schema.withDecodingDefault(() => [])), @@ -348,6 +349,18 @@ const ThreadDeleteCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadArchiveCommand = Schema.Struct({ + type: Schema.Literal("thread.archive"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnarchiveCommand = Schema.Struct({ + type: Schema.Literal("thread.unarchive"), + commandId: CommandId, + threadId: ThreadId, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -457,6 +470,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, + ThreadArchiveCommand, + ThreadUnarchiveCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -476,6 +491,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, + ThreadArchiveCommand, + ThreadUnarchiveCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -576,6 +593,8 @@ export const OrchestrationEventType = Schema.Literals([ "project.deleted", "thread.created", "thread.deleted", + "thread.archived", + "thread.unarchived", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -642,6 +661,17 @@ export const ThreadDeletedPayload = Schema.Struct({ deletedAt: IsoDateTime, }); +export const ThreadArchivedPayload = Schema.Struct({ + threadId: ThreadId, + archivedAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnarchivedPayload = Schema.Struct({ + threadId: ThreadId, + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -799,6 +829,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.deleted"), payload: ThreadDeletedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.archived"), + payload: ThreadArchivedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unarchived"), + payload: ThreadUnarchivedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts new file mode 100644 index 0000000000..e7f638c7f3 --- /dev/null +++ b/packages/contracts/src/settings.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_CLIENT_SETTINGS } from "./settings"; + +describe("DEFAULT_CLIENT_SETTINGS", () => { + it("includes archive confirmation with a false default", () => { + expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8ce01f630c..9cd8a5f251 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,6 +24,7 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const ClientSettingsSchema = Schema.Struct({ + confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( From 3e2df5a7823d180f634370998d58497993b94ee7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 10:51:01 -0700 Subject: [PATCH 16/45] Normalize typed provider runtime ingestion (#1475) --- .../Layers/ProviderRuntimeIngestion.test.ts | 68 ++++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 46 +++---------- 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 8d205bbe2f..3eaeb2cd1d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4,7 +4,6 @@ import path from "node:path"; import type { OrchestrationReadModel, - ProviderKind, ProviderRuntimeEvent, ProviderSession, } from "@t3tools/contracts"; @@ -56,7 +55,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: ProviderKind; + readonly provider: ProviderRuntimeEvent["provider"]; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -66,6 +65,23 @@ type LegacyProviderRuntimeEvent = { readonly [key: string]: unknown; }; +type LegacyTurnCompletedEvent = LegacyProviderRuntimeEvent & { + readonly type: "turn.completed"; + readonly payload?: undefined; + readonly status: "completed" | "failed" | "interrupted" | "cancelled"; + readonly errorMessage?: string | undefined; +}; + +function isLegacyTurnCompletedEvent( + event: LegacyProviderRuntimeEvent, +): event is LegacyTurnCompletedEvent { + return ( + event.type === "turn.completed" && + event.payload === undefined && + typeof event.status === "string" + ); +} + function createProviderServiceHarness() { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); const runtimeSessions: ProviderSession[] = []; @@ -93,8 +109,23 @@ function createProviderServiceHarness() { runtimeSessions.push(session); }; + const normalizeLegacyEvent = (event: LegacyProviderRuntimeEvent): ProviderRuntimeEvent => { + if (isLegacyTurnCompletedEvent(event)) { + const normalized: Extract = { + ...(event as Omit, "payload">), + payload: { + state: event.status, + ...(typeof event.errorMessage === "string" ? { errorMessage: event.errorMessage } : {}), + }, + }; + return normalized; + } + + return event as ProviderRuntimeEvent; + }; + const emit = (event: LegacyProviderRuntimeEvent): void => { - Effect.runSync(PubSub.publish(runtimeEventPubSub, event as unknown as ProviderRuntimeEvent)); + Effect.runSync(PubSub.publish(runtimeEventPubSub, normalizeLegacyEvent(event))); }; return { @@ -1695,6 +1726,37 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime exploded"); }); + it("records runtime.error activities from the typed payload message", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "runtime.error", + eventId: asEventId("evt-runtime-error-activity"), + provider: "codex", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-runtime-error-activity"), + payload: { + message: "runtime activity exploded", + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some((activity) => activity.id === "evt-runtime-error-activity"), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-runtime-error-activity", + ); + const activityPayload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + + expect(activity?.kind).toBe("runtime.error"); + expect(activityPayload?.message).toBe("runtime activity exploded"); + }); + it("keeps the session running when a runtime.warning arrives during an active turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index f9a662b84f..b42e5f1566 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -98,10 +98,6 @@ function proposedPlanIdFromEvent(event: ProviderRuntimeEvent, threadId: ThreadId return `plan:${threadId}:event:${event.eventId}`; } -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - function buildContextWindowActivityPayload( event: ProviderRuntimeEvent, ): ThreadTokenUsageSnapshot | undefined { @@ -111,14 +107,6 @@ function buildContextWindowActivityPayload( return event.payload.usage; } -function runtimePayloadRecord(event: ProviderRuntimeEvent): Record | undefined { - const payload = (event as { payload?: unknown }).payload; - if (!payload || typeof payload !== "object") { - return undefined; - } - return payload as Record; -} - function normalizeRuntimeTurnState( value: string | undefined, ): "completed" | "failed" | "interrupted" | "cancelled" { @@ -133,23 +121,6 @@ function normalizeRuntimeTurnState( } } -function runtimeTurnState( - event: ProviderRuntimeEvent, -): "completed" | "failed" | "interrupted" | "cancelled" { - const payloadState = asString(runtimePayloadRecord(event)?.state); - return normalizeRuntimeTurnState(payloadState); -} - -function runtimeTurnErrorMessage(event: ProviderRuntimeEvent): string | undefined { - const payloadErrorMessage = asString(runtimePayloadRecord(event)?.errorMessage); - return payloadErrorMessage; -} - -function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | undefined { - const payloadMessage = asString(runtimePayloadRecord(event)?.message); - return payloadMessage; -} - function orchestrationSessionStatusFromRuntimeState( state: "starting" | "running" | "waiting" | "ready" | "interrupted" | "stopped" | "error", ): "starting" | "running" | "ready" | "interrupted" | "stopped" | "error" { @@ -253,10 +224,6 @@ function runtimeEventToActivities( } case "runtime.error": { - const message = runtimeErrorMessageFromEvent(event); - if (!message) { - return []; - } return [ { id: event.eventId, @@ -265,7 +232,7 @@ function runtimeEventToActivities( kind: "runtime.error", summary: "Runtime error", payload: { - message: truncateDetail(message), + message: truncateDetail(event.payload.message), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -973,7 +940,9 @@ const make = Effect.gen(function* () { case "session.exited": return "stopped"; case "turn.completed": - return runtimeTurnState(event) === "failed" ? "error" : "ready"; + return normalizeRuntimeTurnState(event.payload.state) === "failed" + ? "error" + : "ready"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -984,8 +953,9 @@ const make = Effect.gen(function* () { const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") - : event.type === "turn.completed" && runtimeTurnState(event) === "failed" - ? (runtimeTurnErrorMessage(event) ?? thread.session?.lastError ?? "Turn failed") + : event.type === "turn.completed" && + normalizeRuntimeTurnState(event.payload.state) === "failed" + ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") : status === "ready" ? null : (thread.session?.lastError ?? null); @@ -1176,7 +1146,7 @@ const make = Effect.gen(function* () { } if (event.type === "runtime.error") { - const runtimeErrorMessage = runtimeErrorMessageFromEvent(event) ?? "Provider runtime error"; + const runtimeErrorMessage = event.payload.message; const shouldApplyRuntimeError = !STRICT_PROVIDER_LIFECYCLE_GUARD ? true From 5851b8aca4a13e1a5c6db644a93c20b779e0e04d Mon Sep 17 00:00:00 2001 From: smalitobules Date: Sat, 28 Mar 2026 19:53:29 +0200 Subject: [PATCH 17/45] fix: trust node-pty install scripts on linux (#1451) Co-authored-by: smalitobules <207318034+smalitobules@users.noreply.github.com> --- bun.lock | 3 +++ package.json | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 4c640ba3c8..fa083cdc53 100644 --- a/bun.lock +++ b/bun.lock @@ -169,6 +169,9 @@ }, }, }, + "trustedDependencies": [ + "node-pty", + ], "overrides": { "vite": "^8.0.0", }, diff --git a/package.json b/package.json index a906779961..33e65e90e9 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,8 @@ "workerDirectory": [ "apps/web/public" ] - } + }, + "trustedDependencies": [ + "node-pty" + ] } From 32c1f98a02d5cf8e42d70dbd65822f9b08151024 Mon Sep 17 00:00:00 2001 From: Ryan Gast <148826144+Ryan-D-Gast@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:21:56 -0500 Subject: [PATCH 18/45] Fix Linux desktop Codex CLI detection at startup (#1100) Co-authored-by: Julius Marminge --- apps/desktop/src/syncShellEnvironment.test.ts | 28 +++++++++++-- apps/desktop/src/syncShellEnvironment.ts | 13 +++++-- apps/server/src/main.test.ts | 19 ++++++++- apps/server/src/os-jank.test.ts | 39 +++++++++++++++++++ apps/server/src/os-jank.ts | 22 ++++++++--- packages/shared/src/shell.ts | 20 ++++++++++ 6 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/os-jank.test.ts diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index 69e73da0aa..cda78a20b2 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -62,14 +62,13 @@ describe("syncShellEnvironment", () => { expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); - it("does nothing outside macOS", () => { + it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin", - SSH_AUTH_SOCK: "/tmp/inherited.sock", }; const readEnvironment = vi.fn(() => ({ - PATH: "/opt/homebrew/bin:/usr/bin", + PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", })); @@ -78,8 +77,29 @@ describe("syncShellEnvironment", () => { readEnvironment, }); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + }); + + it("does nothing outside macOS and linux", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "C:/Program Files/Git/bin/bash.exe", + PATH: "C:\\Windows\\System32", + SSH_AUTH_SOCK: "/tmp/inherited.sock", + }; + const readEnvironment = vi.fn(() => ({ + PATH: "/usr/local/bin:/usr/bin", + SSH_AUTH_SOCK: "/tmp/secretive.sock", + })); + + syncShellEnvironment(env, { + platform: "win32", + readEnvironment, + }); + expect(readEnvironment).not.toHaveBeenCalled(); - expect(env.PATH).toBe("/usr/bin"); + expect(env.PATH).toBe("C:\\Windows\\System32"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 2181bea0ca..13036149b8 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,4 +1,8 @@ -import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell"; +import { + readEnvironmentFromLoginShell, + resolveLoginShell, + ShellEnvironmentReader, +} from "@t3tools/shared/shell"; export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, @@ -7,10 +11,13 @@ export function syncShellEnvironment( readEnvironment?: ShellEnvironmentReader; } = {}, ): void { - if ((options.platform ?? process.platform) !== "darwin") return; + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "linux") return; try { - const shell = env.SHELL ?? "/bin/zsh"; + const shell = resolveLoginShell(platform, env.SHELL); + if (!shell) return; + const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ "PATH", "SSH_AUTH_SOCK", diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 0279e78f7c..0d990a81c8 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -21,6 +21,7 @@ import { ServerSettingsService } from "./serverSettings"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); +const fixPath = vi.fn(() => undefined); let resolvedConfig: ServerConfigShape | null = null; const serverStart = Effect.acquireRelease( Effect.gen(function* () { @@ -36,7 +37,7 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred) const testLayer = Layer.mergeAll( Layer.succeed(CliConfig, { cwd: "/tmp/t3-test-workspace", - fixPath: Effect.void, + fixPath: Effect.sync(fixPath), resolveStaticDir: Effect.undefined, } satisfies CliConfigShape), Layer.succeed(NetService, { @@ -81,6 +82,7 @@ beforeEach(() => { resolvedConfig = null; start.mockImplementation(() => undefined); stop.mockImplementation(() => undefined); + fixPath.mockImplementation(() => undefined); findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); }); @@ -329,6 +331,21 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + it.effect("hydrates PATH before server startup", () => + Effect.gen(function* () { + yield* runCli([]); + + assert.equal(fixPath.mock.calls.length, 1); + assert.equal(start.mock.calls.length, 1); + const fixPathOrder = fixPath.mock.invocationCallOrder[0]; + const startOrder = start.mock.invocationCallOrder[0]; + if (typeof fixPathOrder !== "number" || typeof startOrder !== "number") { + assert.fail("Expected fixPath and start to be called"); + } + assert.isTrue(fixPathOrder < startOrder); + }), + ); + it.effect("records a startup heartbeat with thread/project counts", () => Effect.gen(function* () { const recordTelemetry = vi.fn( diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts new file mode 100644 index 0000000000..ca03ab5868 --- /dev/null +++ b/apps/server/src/os-jank.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fixPath } from "./os-jank"; + +describe("fixPath", () => { + it("hydrates PATH on linux using the resolved login shell", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + + fixPath({ + env, + platform: "linux", + readPath, + }); + + expect(readPath).toHaveBeenCalledWith("/bin/zsh"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + }); + + it("does nothing outside macOS and linux even when SHELL is set", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "C:/Program Files/Git/bin/bash.exe", + PATH: "C:\\Windows\\System32", + }; + const readPath = vi.fn(() => "/usr/local/bin:/usr/bin"); + + fixPath({ + env, + platform: "win32", + readPath, + }); + + expect(readPath).not.toHaveBeenCalled(); + expect(env.PATH).toBe("C:\\Windows\\System32"); + }); +}); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 0721d0d9f8..c3629e8fde 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,15 +1,25 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell } from "@t3tools/shared/shell"; +import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; -export function fixPath(): void { - if (process.platform !== "darwin") return; +export function fixPath( + options: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + readPath?: typeof readPathFromLoginShell; + } = {}, +): void { + const platform = options.platform ?? process.platform; + if (platform !== "darwin" && platform !== "linux") return; + + const env = options.env ?? process.env; try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = readPathFromLoginShell(shell); + const shell = resolveLoginShell(platform, env.SHELL); + if (!shell) return; + const result = (options.readPath ?? readPathFromLoginShell)(shell); if (result) { - process.env.PATH = result; + env.PATH = result; } } catch { // Silently ignore — keep default PATH diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index f1d60bf334..d9e8a7881b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -10,6 +10,26 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +export function resolveLoginShell( + platform: NodeJS.Platform, + shell: string | undefined, +): string | undefined { + const trimmedShell = shell?.trim(); + if (trimmedShell) { + return trimmedShell; + } + + if (platform === "darwin") { + return "/bin/zsh"; + } + + if (platform === "linux") { + return "/bin/bash"; + } + + return undefined; +} + export function extractPathFromShellOutput(output: string): string | null { const startIndex = output.indexOf(PATH_CAPTURE_START); if (startIndex === -1) return null; From 61f983098be44316944e77e3bc81538a6a8fb0e9 Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali <126423069+Marve10s@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:38:29 +0200 Subject: [PATCH 19/45] fix(web): allow switching away from Ultrathink without manual prompt editing (#1307) --- .../CompactComposerControlsMenu.browser.tsx | 25 ++++++++++++++++--- .../components/chat/TraitsPicker.browser.tsx | 22 +++++++++++++--- apps/web/src/components/chat/TraitsPicker.tsx | 24 ++++++++++++++---- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index aa3550e007..eee6f885e9 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -220,7 +220,7 @@ describe("CompactComposerControlsMenu", () => { }); }); - it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { + it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { await using _ = await mountMenu({ modelSelection: { provider: "claudeAgent", @@ -235,8 +235,27 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Effort"); - expect(text).toContain("Remove Ultrathink from the prompt to change effort."); - expect(text).not.toContain("Fallback Effort"); + expect(text).not.toContain("ultrathink"); + }); + }); + + it("warns when ultrathink appears in prompt body text", async () => { + await using _ = await mountMenu({ + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { effort: "high" }, + }, + prompt: "Ultrathink:\nplease ultrathink about this problem", + }); + + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain( + 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', + ); }); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 99d09fd634..9dea3651ea 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -296,7 +296,7 @@ describe("TraitsPicker (Claude)", () => { }); }); - it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => { + it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { await using _ = await mountClaudePicker({ model: "claude-opus-4-6", options: { effort: "high" }, @@ -312,8 +312,24 @@ describe("TraitsPicker (Claude)", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Effort"); - expect(text).toContain("Remove Ultrathink from the prompt to change effort."); - expect(text).not.toContain("Fallback Effort"); + expect(text).not.toContain("ultrathink"); + }); + }); + + it("warns when ultrathink appears in prompt body text", async () => { + await using _ = await mountClaudePicker({ + model: "claude-opus-4-6", + options: { effort: "high" }, + prompt: "Ultrathink:\nplease ultrathink about this problem", + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain( + 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', + ); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index a3b6cbb48f..061594ad53 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -120,6 +120,10 @@ function getSelectedTraits( caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); + // Check if "ultrathink" appears in the body text (not just our prefix) + const ultrathinkInBodyText = + ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); + return { caps, effort, @@ -130,6 +134,7 @@ function getSelectedTraits( contextWindow, defaultContextWindow, ultrathinkPromptControlled, + ultrathinkInBodyText, }; } @@ -176,12 +181,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ contextWindow, defaultContextWindow, ultrathinkPromptControlled, + ultrathinkInBodyText, } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); const defaultEffort = getDefaultEffort(caps); const handleEffortChange = useCallback( (value: string) => { - if (ultrathinkPromptControlled) return; if (!value) return; const nextOption = effortLevels.find((option) => option.value === value); if (!nextOption) return; @@ -193,6 +198,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange(nextPrompt); return; } + if (ultrathinkInBodyText) return; + if (ultrathinkPromptControlled) { + const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); + onPromptChange(stripped); + } const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; updateModelOptions( buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), @@ -200,6 +210,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ }, [ ultrathinkPromptControlled, + ultrathinkInBodyText, modelOptions, onPromptChange, updateModelOptions, @@ -220,17 +231,20 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ <>
Effort
- {ultrathinkPromptControlled ? ( + {ultrathinkInBodyText ? (
- Remove Ultrathink from the prompt to change effort. + Your prompt contains "ultrathink" in the text. Remove it to change effort.
) : null} - + {effortLevels.map((option) => ( {option.label} {option.value === defaultEffort ? " (default)" : ""} From 04298962be548a2d80433d0cc2c777a8b9de12a2 Mon Sep 17 00:00:00 2001 From: Rishi Raj Singh Chauhan <29247443+rishi-chauhan@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:11:26 +0530 Subject: [PATCH 20/45] Fix server fallback to direct fd read on /proc/self/fd failure (#1488) Co-authored-by: Julius Marminge --- apps/server/src/bootstrap.test.ts | 53 ++++++++++++++++++++++++++++++ apps/server/src/bootstrap.ts | 54 +++++++++++++++++++++++-------- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 804f2440a9..3fce6af9c4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -8,10 +8,33 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { TestClock } from "effect/testing"; +import { vi } from "vitest"; import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; import { assertNone, assertSome } from "@effect/vitest/utils"; +const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openSync: (...args: Parameters) => { + const [filePath, flags] = args; + if ( + typeof filePath === "string" && + filePath === openSyncInterceptor.failPath && + flags === "r" + ) { + const error = new Error("no such device or address"); + Object.assign(error, { code: "ENXIO" }); + throw error; + } + return (actual.openSync as (...a: typeof args) => number)(...args); + }, + }; +}); + const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { @@ -47,6 +70,36 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { }), ); + it.effect("falls back to reading the inherited fd when path duplication fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + + yield* fs.writeFileString( + filePath, + `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ + mode: "desktop", + })}\n`, + ); + + // Open without acquireRelease: the direct-stream fallback uses autoClose: true, + // so the stream owns the fd lifecycle and closes it asynchronously on end. + // Attempting to also close it synchronously in a finalizer races with the + // stream's async close and produces an uncaught EBADF. + const fd = NFS.openSync(filePath, "r"); + + openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; + try { + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertSome(payload, { + mode: "desktop", + }); + } finally { + openSyncInterceptor.failPath = null; + } + }), + ); + it.effect("returns none when the fd is unavailable", () => Effect.gen(function* () { const fd = NFS.openSync("/dev/null", "r"); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index b837ac6c18..0fb1352268 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -108,21 +108,26 @@ const makeBootstrapInputStream = (fd: number) => try: () => { const fdPath = resolveFdPath(fd); if (fdPath === undefined) { - const stream = new Net.Socket({ - fd, - readable: true, - writable: false, - }); - stream.setEncoding("utf8"); - return stream; + return makeDirectBootstrapStream(fd); } - const streamFd = NFS.openSync(fdPath, "r"); - return NFS.createReadStream("", { - fd: streamFd, - encoding: "utf8", - autoClose: true, - }); + let streamFd: number | undefined; + try { + streamFd = NFS.openSync(fdPath, "r"); + return NFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + } catch (error) { + if (isBootstrapFdPathDuplicationError(error)) { + if (streamFd !== undefined) { + NFS.closeSync(streamFd); + } + return makeDirectBootstrapStream(fd); + } + throw error; + } }, catch: (error) => new BootstrapError({ @@ -131,6 +136,29 @@ const makeBootstrapInputStream = (fd: number) => }), }); +const makeDirectBootstrapStream = (fd: number): Readable => { + try { + return NFS.createReadStream("", { + fd, + encoding: "utf8", + autoClose: true, + }); + } catch { + const stream = new Net.Socket({ + fd, + readable: true, + writable: false, + }); + stream.setEncoding("utf8"); + return stream; + } +}; + +const isBootstrapFdPathDuplicationError = Predicate.compose( + Predicate.hasProperty("code"), + (_) => _.code === "ENXIO" || _.code === "EINVAL" || _.code === "EPERM", +); + export function resolveFdPath( fd: number, platform: NodeJS.Platform = process.platform, From 52fa6a733342a2e5c5d421e90c8f31755fb76395 Mon Sep 17 00:00:00 2001 From: Daniels <73656233+danielss-dev@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:51:44 +0100 Subject: [PATCH 21/45] fix(desktop): ensure all windows are destroyed before launching the NSIS installer during update process (#1461) Co-authored-by: Daniel Schwarz --- apps/desktop/src/main.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c718a31272..9e2950462e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -810,7 +810,11 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed clearUpdatePollTimer(); try { await stopBackendAndWaitForExit(); - autoUpdater.quitAndInstall(); + // Destroy all windows before launching the NSIS installer to avoid the installer finding live windows it needs to close. + for (const win of BrowserWindow.getAllWindows()) { + win.destroy(); + } + autoUpdater.quitAndInstall(true, true); return { accepted: true, completed: true }; } catch (error: unknown) { const message = formatErrorMessage(error); @@ -1390,7 +1394,7 @@ app }); app.on("window-all-closed", () => { - if (process.platform !== "darwin") { + if (process.platform !== "darwin" && !isQuitting) { app.quit(); } }); From ce463a5360ab4a2426d9744d830266d3ef7f4410 Mon Sep 17 00:00:00 2001 From: Fllip <39161142+FllipEis@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:56:46 +0100 Subject: [PATCH 22/45] fix(web): add project path action to sidebar menu (#1436) Co-authored-by: Julius Marminge --- apps/web/src/components/Sidebar.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af60ce7d29..2d974be3ee 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -818,15 +818,22 @@ export default function Sidebar() { async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); if (!api) return; + const project = projects.find((entry) => entry.id === projectId); + if (!project) return; + const clicked = await api.contextMenu.show( - [{ id: "delete", label: "Remove project", destructive: true }], + [ + { id: "copy-path", label: "Copy Project Path" }, + { id: "delete", label: "Remove project", destructive: true }, + ], position, ); + if (clicked === "copy-path") { + copyPathToClipboard(project.cwd, { path: project.cwd }); + return; + } if (clicked !== "delete") return; - const project = projects.find((entry) => entry.id === projectId); - if (!project) return; - const projectThreads = threads.filter((thread) => thread.projectId === projectId); if (projectThreads.length > 0) { toastManager.add({ @@ -864,6 +871,7 @@ export default function Sidebar() { [ clearComposerDraftForThread, clearProjectDraftThreadId, + copyPathToClipboard, getDraftThreadByProjectId, projects, threads, From 73b2f255a9c81f1efaa4133bee581d071abe6841 Mon Sep 17 00:00:00 2001 From: Danila Yudin Date: Sat, 28 Mar 2026 21:59:56 +0300 Subject: [PATCH 23/45] fix: add noopener to external chat markdown links (#1315) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9663d158eb..b364a8e3a1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -243,7 +243,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { a({ node: _node, href, ...props }) { const targetPath = resolveMarkdownFileLinkTarget(href, cwd); if (!targetPath) { - return
; + return ; } return ( From c426452239e06417ec8e231b7883f9c2292df63c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 15:01:37 -0700 Subject: [PATCH 24/45] Tighten settings nav and add Escape back navigation (#1503) --- .../components/settings/SettingsSidebarNav.tsx | 6 +++--- apps/web/src/routes/settings.tsx | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index ffca1e2092..20914b499d 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -41,8 +41,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { isActive={isActive} className={ isActive - ? "gap-2 px-2 py-2 text-left text-sm text-foreground" - : "gap-2 px-2 py-2 text-left text-sm text-muted-foreground hover:text-foreground/80" + ? "gap-2 px-2 py-2 text-left text-xs text-foreground" + : "gap-2 px-2 py-2 text-left text-xs text-muted-foreground hover:text-foreground/80" } onClick={() => void navigate({ to: item.to, replace: true })} > @@ -68,7 +68,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { window.history.back()} > diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index edee426732..45096fd6d6 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,6 +1,6 @@ import { RotateCcwIcon } from "lucide-react"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; @@ -13,6 +13,21 @@ function SettingsContentLayout() { setRestoreSignal((value) => value + 1), ); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.key === "Escape") { + event.preventDefault(); + window.history.back(); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, []); + return (
From f4617e7b19830111d88b1d914b3ca4e1396912a3 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sat, 28 Mar 2026 18:44:13 -0400 Subject: [PATCH 25/45] feat: add mock update server (#1180) Co-authored-by: Julius Marminge --- .gitignore | 1 + apps/desktop/src/main.ts | 24 +++++++++- .../components/desktopUpdate.logic.test.ts | 13 ++++-- .../web/src/components/desktopUpdate.logic.ts | 2 +- package.json | 1 + scripts/build-desktop-artifact.ts | 42 +++++++++++++++++- scripts/mock-update-server.ts | 44 +++++++++++++++++++ 7 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 scripts/mock-update-server.ts diff --git a/.gitignore b/.gitignore index 471855de15..6e5f8cc59c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ packages/*/dist build/ .logs/ release/ +release-mock/ .t3 .idea/ apps/web/.playwright diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9e2950462e..81a02d446c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -287,10 +287,12 @@ let updatePollTimer: ReturnType | null = null; let updateStartupTimer: ReturnType | null = null; let updateCheckInFlight = false; let updateDownloadInFlight = false; +let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { + if (updateInstallInFlight) return "install"; if (updateDownloadInFlight) return "download"; if (updateCheckInFlight) return "check"; return updateState.errorContext; @@ -807,6 +809,7 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed } isQuitting = true; + updateInstallInFlight = true; clearUpdatePollTimer(); try { await stopBackendAndWaitForExit(); @@ -814,10 +817,14 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed for (const win of BrowserWindow.getAllWindows()) { win.destroy(); } + // `quitAndInstall()` only starts the handoff to the updater. The actual + // install may still fail asynchronously, so keep the action incomplete + // until we either quit or receive an updater error. autoUpdater.quitAndInstall(true, true); - return { accepted: true, completed: true }; + return { accepted: true, completed: false }; } catch (error: unknown) { const message = formatErrorMessage(error); + updateInstallInFlight = false; isQuitting = false; setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); console.error(`[desktop-updater] Failed to install update: ${message}`); @@ -854,6 +861,13 @@ function configureAutoUpdater(): void { } } + if (process.env.T3CODE_DESKTOP_MOCK_UPDATES) { + autoUpdater.setFeedURL({ + provider: "generic", + url: `http://localhost:${process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000}`, + }); + } + autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; // Keep alpha branding, but force all installs onto the stable update track. @@ -890,6 +904,13 @@ function configureAutoUpdater(): void { }); autoUpdater.on("error", (error) => { const message = formatErrorMessage(error); + if (updateInstallInFlight) { + updateInstallInFlight = false; + isQuitting = false; + setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); + console.error(`[desktop-updater] Updater error: ${message}`); + return; + } if (!updateCheckInFlight && !updateDownloadInFlight) { setUpdateState({ status: "error", @@ -1365,6 +1386,7 @@ async function bootstrap(): Promise { app.on("before-quit", () => { isQuitting = true; + updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); stopBackend(); diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b1..340b9a0164 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -145,19 +145,26 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { - it("toasts only for accepted incomplete actions", () => { + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ accepted: true, completed: false, - state: baseState, + state: { ...baseState, message: "checksum mismatch" }, }), ).toBe(true); + expect( + shouldToastDesktopUpdateActionResult({ + accepted: true, + completed: false, + state: { ...baseState, message: null }, + }), + ).toBe(false); expect( shouldToastDesktopUpdateActionResult({ accepted: true, completed: true, - state: baseState, + state: { ...baseState, message: "checksum mismatch" }, }), ).toBe(false); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index faf30883cc..5c9c303026 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -87,7 +87,7 @@ export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): } export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { - return result.accepted && !result.completed; + return getDesktopUpdateActionError(result) !== null; } export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { diff --git a/package.json b/package.json index 33e65e90e9..6f14f7b77f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "start:marketing": "turbo run preview --filter=@t3tools/marketing", + "start:mock-update-server": "bun run scripts/mock-update-server.ts", "build": "turbo run build", "build:marketing": "turbo run build --filter=@t3tools/marketing", "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=t3", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 0b875721fd..4f1886c4ad 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -74,6 +74,8 @@ interface BuildCliInput { readonly keepStage: Option.Option; readonly signed: Option.Option; readonly verbose: Option.Option; + readonly mockUpdates: Option.Option; + readonly mockUpdateServerPort: Option.Option; } function detectHostBuildPlatform(hostPlatform: string): typeof BuildPlatform.Type | undefined { @@ -162,6 +164,8 @@ interface ResolvedBuildOptions { readonly keepStage: boolean; readonly signed: boolean; readonly verbose: boolean; + readonly mockUpdates: boolean; + readonly mockUpdateServerPort: string | undefined; } interface StagePackageJson { @@ -204,6 +208,8 @@ const BuildEnvConfig = Config.all({ keepStage: Config.boolean("T3CODE_DESKTOP_KEEP_STAGE").pipe(Config.withDefault(false)), signed: Config.boolean("T3CODE_DESKTOP_SIGNED").pipe(Config.withDefault(false)), verbose: Config.boolean("T3CODE_DESKTOP_VERBOSE").pipe(Config.withDefault(false)), + mockUpdates: Config.boolean("T3CODE_DESKTOP_MOCK_UPDATES").pipe(Config.withDefault(false)), + mockUpdateServerPort: Config.string("T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT").pipe(Config.option), }); const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => @@ -231,13 +237,26 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B const target = mergeOptions(input.target, env.target, PLATFORM_CONFIG[platform].defaultTarget); const arch = mergeOptions(input.arch, env.arch, getDefaultArch(platform)); const version = mergeOptions(input.buildVersion, env.version, undefined); - const outputDir = path.resolve(repoRoot, mergeOptions(input.outputDir, env.outputDir, "release")); + const releaseDir = resolveBooleanFlag(input.mockUpdates, env.mockUpdates) + ? "release-mock" + : "release"; + const outputDir = path.resolve( + repoRoot, + mergeOptions(input.outputDir, env.outputDir, releaseDir), + ); const skipBuild = resolveBooleanFlag(input.skipBuild, env.skipBuild); const keepStage = resolveBooleanFlag(input.keepStage, env.keepStage); const signed = resolveBooleanFlag(input.signed, env.signed); const verbose = resolveBooleanFlag(input.verbose, env.verbose); + const mockUpdates = resolveBooleanFlag(input.mockUpdates, env.mockUpdates); + const mockUpdateServerPort = mergeOptions( + input.mockUpdateServerPort, + env.mockUpdateServerPort, + undefined, + ); + return { platform, target, @@ -248,6 +267,8 @@ const resolveBuildOptions = Effect.fn("resolveBuildOptions")(function* (input: B keepStage, signed, verbose, + mockUpdates, + mockUpdateServerPort, } satisfies ResolvedBuildOptions; }); @@ -447,6 +468,8 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: string, productName: string, signed: boolean, + mockUpdates: boolean, + mockUpdateServerPort: string | undefined, ) { const buildConfig: Record = { appId: "com.t3tools.t3code", @@ -459,6 +482,13 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const publishConfig = resolveGitHubPublishConfig(); if (publishConfig) { buildConfig.publish = [publishConfig]; + } else if (mockUpdates) { + buildConfig.publish = [ + { + provider: "generic", + url: `http://localhost:${mockUpdateServerPort ?? 3000}`, + }, + ]; } if (platform === "mac") { @@ -631,6 +661,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.target, desktopPackageJson.productName ?? "T3 Code", options.signed, + options.mockUpdates, + options.mockUpdateServerPort, ), dependencies: { ...resolvedServerDependencies, @@ -769,6 +801,14 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { Flag.withDescription("Stream subprocess stdout (env: T3CODE_DESKTOP_VERBOSE)."), Flag.optional, ), + mockUpdates: Flag.boolean("mock-updates").pipe( + Flag.withDescription("Enable mock updates (env: T3CODE_DESKTOP_MOCK_UPDATES)."), + Flag.optional, + ), + mockUpdateServerPort: Flag.string("mock-update-server-port").pipe( + Flag.withDescription("Mock update server port (env: T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT)."), + Flag.optional, + ), }).pipe( Command.withDescription("Build a desktop artifact for T3 Code."), Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts new file mode 100644 index 0000000000..57dab49ffa --- /dev/null +++ b/scripts/mock-update-server.ts @@ -0,0 +1,44 @@ +import { resolve, relative } from "node:path"; +import { realpathSync } from "node:fs"; + +const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); +const root = + process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_ROOT ?? + resolve(import.meta.dirname, "..", "release-mock"); + +const mockServerLog = (level: "info" | "warn" | "error" = "info", message: string) => { + console[level](`[mock-update-server] ${message}`); +}; + +function isWithinRoot(filePath: string): boolean { + try { + return !relative(realpathSync(root), realpathSync(filePath)).startsWith("."); + } catch (error) { + mockServerLog("error", `Error checking if file is within root: ${error}`); + return false; + } +} + +Bun.serve({ + port, + hostname: "localhost", + fetch: async (request) => { + const url = new URL(request.url); + const path = url.pathname; + mockServerLog("info", `Request received for path: ${path}`); + const filePath = resolve(root, `.${path}`); + if (!isWithinRoot(filePath)) { + mockServerLog("warn", `Attempted to access file outside of root: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } + const file = Bun.file(filePath); + if (!(await file.exists())) { + mockServerLog("warn", `Attempted to access non-existent file: ${filePath}`); + return new Response("Not Found", { status: 404 }); + } + mockServerLog("info", `Serving file: ${filePath}`); + return new Response(file.stream()); + }, +}); + +mockServerLog("info", `running on http://localhost:${port}`); From 5b1d5c1220165db816ea5eee15436df778a11aac Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 28 Mar 2026 19:49:53 -0300 Subject: [PATCH 26/45] fix(ci): use whitespace-insensitive PR size diff (#1500) --- .github/workflows/pr-size.yml | 99 ++++++++++++++++++++++++----------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index ced845a28f..15c04d663d 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -124,25 +124,49 @@ jobs: group: pr-size-${{ github.event.pull_request.number }} cancel-in-progress: true steps: + - name: Checkout base repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Sync PR size label uses: actions/github-script@v8 env: PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} with: script: | + const { execFileSync } = require("node:child_process"); + const issueNumber = context.payload.pull_request.number; + const baseSha = context.payload.pull_request.base.sha; + const headSha = context.payload.pull_request.head.sha; + const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`; const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); const managedLabelNames = new Set(managedLabels.map((label) => label.name)); // Keep this aligned with the repo's test entrypoints and test-only support files. - const testFilePatterns = [ - /(^|\/)__tests__(\/|$)/, - /(^|\/)tests?(\/|$)/, - /^apps\/server\/integration\//, - /\.(test|spec|browser|integration)\.[^.\/]+$/, + const testExcludePathspecs = [ + ":(glob,exclude)**/__tests__/**", + ":(glob,exclude)**/test/**", + ":(glob,exclude)**/tests/**", + ":(glob,exclude)apps/server/integration/**", + ":(glob,exclude)**/*.test.*", + ":(glob,exclude)**/*.spec.*", + ":(glob,exclude)**/*.browser.*", + ":(glob,exclude)**/*.integration.*", ]; - const isTestFile = (filename) => - testFilePatterns.some((pattern) => pattern.test(filename)); + const sumNumstat = (text) => + text + .split("\n") + .filter(Boolean) + .reduce((total, line) => { + const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t"); + const additions = + insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0; + const deletions = + deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0; + + return total + additions + deletions; + }, 0); const resolveSizeLabel = (totalChangedLines) => { if (totalChangedLines < 10) { @@ -168,40 +192,53 @@ jobs: return "size:XXL"; }; - const files = await github.paginate( - github.rest.pulls.listFiles, + execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { + stdio: "inherit", + }); + + execFileSync( + "git", + ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`], { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: issueNumber, - per_page: 100, + stdio: "inherit", }, - (response) => response.data, ); - if (files.length >= 3000) { + const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { + encoding: "utf8", + }).trim(); + + if (resolvedHeadSha !== headSha) { core.warning( - "The GitHub pull request files API may truncate results at 3,000 files; PR size may be undercounted.", + `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`, ); } - let testChangedLines = 0; - let nonTestChangedLines = 0; - - for (const file of files) { - const changedLinesForFile = (file.additions ?? 0) + (file.deletions ?? 0); - - if (changedLinesForFile === 0) { - continue; - } + execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { + stdio: "inherit", + }); - if (isTestFile(file.filename)) { - testChangedLines += changedLinesForFile; - continue; - } + const diffArgs = [ + "diff", + "--numstat", + "--ignore-all-space", + "--ignore-blank-lines", + `${baseSha}...${resolvedHeadSha}`, + ]; - nonTestChangedLines += changedLinesForFile; - } + const totalChangedLines = sumNumstat( + execFileSync( + "git", + diffArgs, + { encoding: "utf8" }, + ), + ); + const nonTestChangedLines = sumNumstat( + execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { + encoding: "utf8", + }), + ); + const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines); const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; const nextLabelName = resolveSizeLabel(changedLines); From 5210a833a58d24388611fb6a47b56a460a96422f Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:28:37 +0530 Subject: [PATCH 27/45] Fix sidebar thread panel weird states. (#1497) Co-authored-by: Julius Marminge --- apps/web/src/components/Sidebar.tsx | 81 ++++++++++++++--------------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2d974be3ee..299948c862 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -72,7 +72,6 @@ import { } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; -import { Collapsible, CollapsibleContent } from "./ui/collapsible"; import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { @@ -1228,7 +1227,7 @@ export default function Sidebar() { }; return ( - + <>
- - - {renderedThreads.map((thread) => renderThreadRow(thread))} - - {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - Show more - - - )} - {project.expanded && hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} - - -
+ + {shouldShowThreadPanel && renderedThreads.map((thread) => renderThreadRow(thread))} + + {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => { + expandThreadListForProject(project.id); + }} + > + Show more + + + )} + {project.expanded && hasHiddenThreads && isThreadListExpanded && ( + + } + data-thread-selection-safe + size="sm" + className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" + onClick={() => { + collapseThreadListForProject(project.id); + }} + > + Show less + + + )} + + ); } From 64d21bd6d29fbf795262fd6f031db81e3ddbad02 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Sun, 29 Mar 2026 12:09:19 +1300 Subject: [PATCH 28/45] fix: Prevent sidebar project dragging when using macos control+click to open context menu (#873) Co-authored-by: Julius Marminge --- apps/web/src/components/Sidebar.logic.test.ts | 33 +++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 9 +++++ apps/web/src/components/Sidebar.tsx | 40 +++++++++++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0824758baa..cc49a82e69 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,6 +5,7 @@ import { getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -96,6 +97,38 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("isContextMenuPointerDown", () => { + it("treats secondary-button presses as context menu gestures on all platforms", () => { + expect( + isContextMenuPointerDown({ + button: 2, + ctrlKey: false, + isMac: false, + }), + ).toBe(true); + }); + + it("treats ctrl+primary-click as a context menu gesture on macOS", () => { + expect( + isContextMenuPointerDown({ + button: 0, + ctrlKey: true, + isMac: true, + }), + ).toBe(true); + }); + + it("does not treat ctrl+primary-click as a context menu gesture off macOS", () => { + expect( + isContextMenuPointerDown({ + button: 0, + ctrlKey: true, + isMac: false, + }), + ).toBe(false); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6ca29d27e9..22a6268ac7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -67,6 +67,15 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function isContextMenuPointerDown(input: { + button: number; + ctrlKey: boolean; + isMac: boolean; +}): boolean { + if (input.button === 2) return true; + return input.isMac && input.button === 0 && input.ctrlKey; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 299948c862..d6ef9fc819 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -13,7 +13,15 @@ import { } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; -import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, + type PointerEvent, +} from "react"; import { DndContext, type DragCancelEvent, @@ -93,6 +101,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getVisibleThreadsForProject, + isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -348,6 +357,7 @@ export default function Sidebar() { const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -941,9 +951,24 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const handleProjectTitlePointerDownCapture = useCallback(() => { - suppressProjectClickAfterDragRef.current = false; - }, []); + const handleProjectTitlePointerDownCapture = useCallback( + (event: PointerEvent) => { + suppressProjectClickForContextMenuRef.current = false; + if ( + isContextMenuPointerDown({ + button: event.button, + ctrlKey: event.ctrlKey, + isMac: isMacPlatform(navigator.platform), + }) + ) { + // Keep context-menu gestures from arming the sortable drag sensor. + event.stopPropagation(); + } + + suppressProjectClickAfterDragRef.current = false; + }, + [], + ); const visibleThreads = useMemo( () => threads.filter((thread) => thread.archivedAt === null), @@ -1242,6 +1267,7 @@ export default function Sidebar() { onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} onContextMenu={(event) => { event.preventDefault(); + suppressProjectClickForContextMenuRef.current = true; void handleProjectContextMenu(project.id, { x: event.clientX, y: event.clientY, @@ -1351,6 +1377,12 @@ export default function Sidebar() { const handleProjectTitleClick = useCallback( (event: React.MouseEvent, projectId: ProjectId) => { + if (suppressProjectClickForContextMenuRef.current) { + suppressProjectClickForContextMenuRef.current = false; + event.preventDefault(); + event.stopPropagation(); + return; + } if (dragInProgressRef.current) { event.preventDefault(); event.stopPropagation(); From 33773ff17eb7dcdb3a020b0d0a1c27ac0df86125 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 16:13:52 -0700 Subject: [PATCH 29/45] Harden Claude stream exit handling (#1504) --- .../src/provider/Layers/ClaudeAdapter.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b0f080118e..6b50bd4fbb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2817,16 +2817,25 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( providerRefs: {}, }); - const streamFiber = runFork(runSdkStream(context)); + let streamFiber: Fiber.Fiber; + streamFiber = runFork( + Effect.exit(runSdkStream(context)).pipe( + Effect.flatMap((exit) => { + if (context.stopped) { + return Effect.void; + } + if (context.streamFiber === streamFiber) { + context.streamFiber = undefined; + } + return handleStreamExit(context, exit); + }), + ), + ); context.streamFiber = streamFiber; - streamFiber.addObserver((exit) => { - if (context.stopped) { - return; - } + streamFiber.addObserver(() => { if (context.streamFiber === streamFiber) { context.streamFiber = undefined; } - runFork(handleStreamExit(context, exit)); }); return { From 5513845fd56acf7a019d24e72fc4e11bf5654f50 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 28 Mar 2026 21:03:01 -0300 Subject: [PATCH 30/45] feat(threads): auto-generate first-turn thread titles (#1375) --- .../OrchestrationEngineHarness.integration.ts | 3 +- .../git/Layers/ClaudeTextGeneration.test.ts | 59 +++++ .../src/git/Layers/ClaudeTextGeneration.ts | 37 +++- .../git/Layers/CodexTextGeneration.test.ts | 64 ++++++ .../src/git/Layers/CodexTextGeneration.ts | 50 ++++- apps/server/src/git/Layers/GitManager.test.ts | 25 ++- .../src/git/Layers/RoutingTextGeneration.ts | 1 + apps/server/src/git/Prompts.test.ts | 45 +++- apps/server/src/git/Prompts.ts | 65 +++++- .../server/src/git/Services/TextGeneration.ts | 20 ++ apps/server/src/git/Utils.ts | 21 ++ .../Layers/ProviderCommandReactor.test.ts | 209 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 104 +++++++-- apps/server/src/orchestration/decider.ts | 2 +- apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/truncateTitle.test.ts | 17 -- packages/contracts/src/orchestration.test.ts | 31 +++ packages/contracts/src/orchestration.ts | 5 +- packages/shared/package.json | 4 + packages/shared/src/String.test.ts | 17 ++ .../shared/src/String.ts | 3 +- 21 files changed, 732 insertions(+), 59 deletions(-) delete mode 100644 apps/web/src/truncateTitle.test.ts create mode 100644 packages/shared/src/String.test.ts rename apps/web/src/truncateTitle.ts => packages/shared/src/String.ts (66%) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 115d18d02b..1a8f802d73 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -306,7 +306,8 @@ export const makeOrchestrationIntegrationHarness = ( Effect.succeed({ branch: input.newBranch }), } as unknown as GitCoreShape); const textGenerationLayer = Layer.succeed(TextGeneration, { - generateBranchName: () => Effect.succeed({ branch: null }), + generateBranchName: () => Effect.succeed({ branch: "update" }), + generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 29ae4796fe..0847134698 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -5,6 +5,7 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { sanitizeThreadTitle } from "../Utils.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -247,4 +248,62 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { }), ), ); + + it.effect("generates thread titles through the Claude provider", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: + ' "Reconnect failures after restart because the session state does not recover" ', + }, + }), + stdinMustContain: "You write concise thread titles for coding conversations.", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate reconnect failures after restarting the session.", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe( + sanitizeThreadTitle( + '"Reconnect failures after restart because the session state does not recover"', + ), + ); + }), + ), + ); + + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: ' """ """ ', + }, + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); }); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 919c3a323d..f4d3833627 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -20,11 +20,13 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "../Prompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, + sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; @@ -70,7 +72,11 @@ const makeClaudeTextGeneration = Effect.gen(function* () { outputSchemaJson, modelSelection, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -299,10 +305,39 @@ const makeClaudeTextGeneration = Effect.gen(function* () { }; }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "ClaudeTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "claudeAgent") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runClaudeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1b07d87d90..21a97eec9c 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -358,6 +358,70 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("generates thread titles and trims them for sidebar use", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: + ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate websocket reconnect regressions after a worktree restore.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + }), + ), + ); + + it.effect("falls back when thread title normalization becomes whitespace-only", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ' """ """ ', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); + + it.effect("trims whitespace exposed after quote removal in thread titles", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ` "' hello world '" `, + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("hello world"); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f332bf13e..c82923f93e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -11,6 +11,7 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -18,11 +19,13 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "../Prompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, + sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; @@ -30,7 +33,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; - const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -83,7 +85,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); const materializeImageAttachments = ( - _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + _operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", attachments: BranchNameGenerationInput["attachments"], ): Effect.Effect => Effect.gen(function* () { @@ -124,7 +130,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { cleanupPaths = [], modelSelection, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -363,10 +373,44 @@ const makeCodexTextGeneration = Effect.gen(function* () { }; }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CodexTextGeneration.generateThreadTitle", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "codex") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 57d6853c4a..d13c389fc8 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -6,7 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; -import type { GitActionProgressEvent } from "@t3tools/contracts"; +import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; @@ -49,6 +49,7 @@ interface FakeGitTextGeneration { stagedSummary: string; stagedPatch: string; includeBranch?: boolean; + modelSelection: ModelSelection; }) => Effect.Effect< { subject: string; body: string; branch?: string | undefined }, TextGenerationError @@ -60,11 +61,18 @@ interface FakeGitTextGeneration { commitSummary: string; diffSummary: string; diffPatch: string; + modelSelection: ModelSelection; }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; generateBranchName: (input: { cwd: string; message: string; + modelSelection: ModelSelection; }) => Effect.Effect<{ branch: string }, TextGenerationError>; + generateThreadTitle: (input: { + cwd: string; + message: string; + modelSelection: ModelSelection; + }) => Effect.Effect<{ title: string }, TextGenerationError>; } type FakePullRequest = NonNullable; @@ -168,6 +176,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ branch: "update-workflow", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), ...overrides, }; @@ -205,6 +217,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateThreadTitle: (input) => + implementation.generateThreadTitle(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 7915131385..dee12a3e0e 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -47,6 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () { route(input.modelSelection.provider).generateCommitMessage(input), generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), + generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts index 23c3eca557..7951e78b39 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/git/Prompts.test.ts @@ -4,8 +4,9 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "./Prompts.ts"; -import { normalizeCliError } from "./Utils.ts"; +import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; import { TextGenerationError } from "./Errors.ts"; describe("buildCommitMessagePrompt", () => { @@ -103,6 +104,48 @@ describe("buildBranchNamePrompt", () => { }); }); +describe("buildThreadTitlePrompt", () => { + it("includes the user message in the prompt", () => { + const result = buildThreadTitlePrompt({ + message: "Investigate reconnect regressions after session restore", + }); + + expect(result.prompt).toContain("User message:"); + expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); + expect(result.prompt).not.toContain("Attachment metadata:"); + }); + + it("includes attachment metadata when attachments are provided", () => { + const result = buildThreadTitlePrompt({ + message: "Name this thread from the screenshot", + attachments: [ + { + type: "image" as const, + id: "att-456", + name: "thread.png", + mimeType: "image/png", + sizeBytes: 67890, + }, + ], + }); + + expect(result.prompt).toContain("Attachment metadata:"); + expect(result.prompt).toContain("thread.png"); + expect(result.prompt).toContain("image/png"); + expect(result.prompt).toContain("67890 bytes"); + }); +}); + +describe("sanitizeThreadTitle", () => { + it("truncates long titles with the shared sidebar-safe limit", () => { + expect( + sanitizeThreadTitle( + ' "Reconnect failures after restart because the session state does not recover" ', + ), + ).toBe("Reconnect failures after restart because the se..."); + }); +}); + describe("normalizeCliError", () => { it("detects 'Command not found' and includes CLI name in the message", () => { const error = normalizeCliError( diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts index 2eacf370eb..4092358825 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/git/Prompts.ts @@ -119,19 +119,24 @@ export interface BranchNamePromptInput { attachments?: ReadonlyArray | undefined; } -export function buildBranchNamePrompt(input: BranchNamePromptInput) { +interface PromptFromMessageInput { + instruction: string; + responseShape: string; + rules: ReadonlyArray; + message: string; + attachments?: ReadonlyArray | undefined; +} + +function buildPromptFromMessage(input: PromptFromMessageInput): string { const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); const promptSections = [ - "You generate concise git branch names.", - "Return a JSON object with key: branch.", + input.instruction, + input.responseShape, "Rules:", - "- Branch should describe the requested work from the user message.", - "- Keep it short and specific (2-6 words).", - "- Use plain words only, no issue prefixes and no punctuation-heavy text.", - "- If images are attached, use them as primary context for visual/UI issues.", + ...input.rules.map((rule) => `- ${rule}`), "", "User message:", limitSection(input.message, 8_000), @@ -144,10 +149,54 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { ); } - const prompt = promptSections.join("\n"); + return promptSections.join("\n"); +} + +export function buildBranchNamePrompt(input: BranchNamePromptInput) { + const prompt = buildPromptFromMessage({ + instruction: "You generate concise git branch names.", + responseShape: "Return a JSON object with key: branch.", + rules: [ + "Branch should describe the requested work from the user message.", + "Keep it short and specific (2-6 words).", + "Use plain words only, no issue prefixes and no punctuation-heavy text.", + "If images are attached, use them as primary context for visual/UI issues.", + ], + message: input.message, + attachments: input.attachments, + }); const outputSchema = Schema.Struct({ branch: Schema.String, }); return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Thread title +// --------------------------------------------------------------------------- + +export interface ThreadTitlePromptInput { + message: string; + attachments?: ReadonlyArray | undefined; +} + +export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { + const prompt = buildPromptFromMessage({ + instruction: "You write concise thread titles for coding conversations.", + responseShape: "Return a JSON object with key: title.", + rules: [ + "Title should summarize the user's request, not restate it verbatim.", + "Keep it short and specific (3-8 words).", + "Avoid quotes, filler, prefixes, and trailing punctuation.", + "If images are attached, use them as primary context for visual/UI issues.", + ], + message: input.message, + attachments: input.attachments, + }); + const outputSchema = Schema.Struct({ + title: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index e9f2230f43..0df2fff62c 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -61,12 +61,25 @@ export interface BranchNameGenerationResult { branch: string; } +export interface ThreadTitleGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ThreadTitleGenerationResult { + title: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, ): Promise; generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; + generateThreadTitle(input: ThreadTitleGenerationInput): Promise; } /** @@ -93,6 +106,13 @@ export interface TextGenerationShape { readonly generateBranchName: ( input: BranchNameGenerationInput, ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index eb208deccb..8f0321fd52 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -53,6 +53,27 @@ export function sanitizePrTitle(raw: string): string { return "Update project changes"; } +/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ +export function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + + if (normalized.length <= 50) { + return normalized; + } + + return `${normalized.slice(0, 47).trimEnd()}...`; +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 834ab9be9e..4e87390eb1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -176,7 +176,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -184,6 +184,14 @@ describe("ProviderCommandReactor", () => { }), ), ); + const generateThreadTitle = vi.fn((_) => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "disabled in test harness", + }), + ), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -213,7 +221,10 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), + Layer.mock(TextGeneration, { + generateBranchName, + generateThreadTitle, + }), ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -264,6 +275,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, generateBranchName, + generateThreadTitle, stateDir, drain, }; @@ -308,6 +320,199 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("generates a thread title on the first turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const seededTitle = "Please investigate reconnect failures after restar..."; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-title-seed"), + threadId: ThreadId.makeUnsafe("thread-1"), + title: seededTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ + message: "Please investigate reconnect failures after restarting the session.", + }); + + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + "Generated title" + ); + }); + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Generated title"); + }); + + it("does not overwrite an existing custom thread title on the first turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const seededTitle = "Please investigate reconnect failures after restar..."; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-title-custom"), + threadId: ThreadId.makeUnsafe("thread-1"), + title: "Keep this custom title", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title-preserve"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title-preserve"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Keep this custom title"); + }); + + it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const seededTitle = "Fix reconnect spinner on resume"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ + title: "Reconnect spinner resume bug", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-title-formatted-seed"), + threadId: ThreadId.makeUnsafe("thread-1"), + title: seededTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title-formatted"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title-formatted"), + role: "user", + text: "[effort:high]\\n\\nFix reconnect spinner on resume", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + "Reconnect spinner resume bug" + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Reconnect spinner resume bug"); + }); + + it("generates a worktree branch name for the first turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + harness.generateBranchName.mockImplementation((input: unknown) => + Effect.succeed({ + branch: + typeof input === "object" && + input !== null && + "modelSelection" in input && + typeof input.modelSelection === "object" && + input.modelSelection !== null && + "model" in input.modelSelection && + typeof input.modelSelection.model === "string" + ? `feature/${input.modelSelection.model}` + : "feature/generated", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-branch-model"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-branch-model"), + role: "user", + text: "Add a safer reconnect backoff.", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ + message: "Add a safer reconnect backoff.", + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7c522e5799..f65137f4b5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -73,6 +73,19 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const DEFAULT_THREAD_TITLE = "New thread"; + +function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; +} function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); @@ -400,7 +413,6 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; - readonly messageId: string; readonly messageText: string; readonly attachments?: ReadonlyArray; }) { @@ -411,16 +423,6 @@ const make = Effect.gen(function* () { return; } - const thread = yield* resolveThread(input.threadId); - if (!thread) { - return; - } - - const userMessages = thread.messages.filter((message) => message.role === "user"); - if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { - return; - } - const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; @@ -459,6 +461,49 @@ const make = Effect.gen(function* () { ); }); + const maybeGenerateThreadTitleForFirstTurn = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + readonly titleSeed?: string; + }) { + const attachments = input.attachments ?? []; + yield* Effect.gen(function* () { + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }); + if (!generated) return; + + const thread = yield* resolveThread(input.threadId); + if (!thread) return; + if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { + return; + } + + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("thread-title-rename"), + threadId: input.threadId, + title: generated.title, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename thread title", { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -485,14 +530,35 @@ const make = Effect.gen(function* () { return; } - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - messageId: message.id, - messageText: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - }).pipe(Effect.forkScoped); + const isFirstUserMessageTurn = + thread.messages.filter((entry) => entry.role === "user").length === 1; + if (isFirstUserMessageTurn) { + const generationCwd = + resolveThreadWorkspaceCwd({ + thread, + projects: (yield* orchestrationEngine.getReadModel()).projects, + }) ?? process.cwd(); + const generationInput = { + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), + }; + + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + + if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + yield* maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: generationCwd, + ...generationInput, + }).pipe(Effect.forkScoped); + } + } yield* sendTurnForThread({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c70194befa..22f5bcb280 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -16,7 +16,6 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); - const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], aggregateKind: "thread", @@ -376,6 +375,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), + ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308..e572f3c470 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,6 +22,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -69,7 +70,6 @@ import { proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; -import { truncateTitle } from "../truncateTitle"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -2625,7 +2625,7 @@ export default function ChatView({ threadId }: ChatViewProps) { titleSeed = "New thread"; } } - const title = truncateTitle(titleSeed); + const title = truncate(titleSeed); const threadCreateModelSelection: ModelSelection = { provider: selectedProvider, model: @@ -2710,6 +2710,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, + titleSeed: title, runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2991,6 +2992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + titleSeed: activeThread.title, runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -3070,7 +3072,7 @@ export default function ChatView({ threadId }: ChatViewProps) { effort: selectedPromptEffort, text: implementationPrompt, }); - const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; @@ -3106,6 +3108,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + titleSeed: nextThreadTitle, runtimeMode, interactionMode: "default", createdAt, diff --git a/apps/web/src/truncateTitle.test.ts b/apps/web/src/truncateTitle.test.ts deleted file mode 100644 index d7d61c5da1..0000000000 --- a/apps/web/src/truncateTitle.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { truncateTitle } from "./truncateTitle"; - -describe("truncateTitle", () => { - it("trims surrounding whitespace", () => { - expect(truncateTitle(" hello world ")).toBe("hello world"); - }); - - it("returns trimmed text when within max length", () => { - expect(truncateTitle("alpha", 10)).toBe("alpha"); - }); - - it("appends ellipsis when text exceeds max length", () => { - expect(truncateTitle("abcdefghij", 5)).toBe("abcde..."); - }); -}); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 59c023e62a..06bb35038d 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -319,6 +319,25 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("accepts a title seed in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-title-seed", + threadId: "thread-1", + message: { + messageId: "msg-title-seed", + role: "user", + text: "hello", + attachments: [], + }, + titleSeed: "Investigate reconnect failures", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ @@ -378,6 +397,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); +it.effect("decodes thread.turn-start-requested title seed when present", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-2", + messageId: "msg-2", + titleSeed: "Investigate reconnect failures", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures"); + }), +); + it.effect("decodes latest turn source proposed plan metadata when present", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationLatestTurn({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 31631666e2..a780a55c78 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -42,6 +42,7 @@ export const ProviderSandboxMode = Schema.Literals([ "danger-full-access", ]); export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; + export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ @@ -398,6 +399,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -417,6 +419,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -711,7 +714,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), + titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), diff --git a/packages/shared/package.json b/packages/shared/package.json index d34d1ce453..40ffbf35c2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -35,6 +35,10 @@ "./Struct": { "types": "./src/Struct.ts", "import": "./src/Struct.ts" + }, + "./String": { + "types": "./src/String.ts", + "import": "./src/String.ts" } }, "scripts": { diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts new file mode 100644 index 0000000000..d70bfe840f --- /dev/null +++ b/packages/shared/src/String.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { truncate } from "./String"; + +describe("truncate", () => { + it("trims surrounding whitespace", () => { + expect(truncate(" hello world ")).toBe("hello world"); + }); + + it("returns shorter strings unchanged", () => { + expect(truncate("alpha", 10)).toBe("alpha"); + }); + + it("truncates long strings and appends an ellipsis", () => { + expect(truncate("abcdefghij", 5)).toBe("abcde..."); + }); +}); diff --git a/apps/web/src/truncateTitle.ts b/packages/shared/src/String.ts similarity index 66% rename from apps/web/src/truncateTitle.ts rename to packages/shared/src/String.ts index bce5545283..c93d0c90cb 100644 --- a/apps/web/src/truncateTitle.ts +++ b/packages/shared/src/String.ts @@ -1,7 +1,8 @@ -export function truncateTitle(text: string, maxLength = 50): string { +export function truncate(text: string, maxLength = 50): string { const trimmed = text.trim(); if (trimmed.length <= maxLength) { return trimmed; } + return `${trimmed.slice(0, maxLength)}...`; } From 58ba4f12633ef05f7a8ab707cba4cd7ccaad0d58 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 28 Mar 2026 18:23:30 -0700 Subject: [PATCH 31/45] Add keyboard shortcuts for jumping to sidebar threads (#1456) Co-authored-by: Julius Marminge --- apps/server/src/keybindings.test.ts | 13 + apps/server/src/keybindings.ts | 7 + apps/web/src/components/ChatView.tsx | 34 +- apps/web/src/components/Sidebar.logic.test.ts | 76 +++++ apps/web/src/components/Sidebar.logic.ts | 41 +++ apps/web/src/components/Sidebar.tsx | 318 ++++++++++++++---- apps/web/src/keybindings.test.ts | 127 ++++++- apps/web/src/keybindings.ts | 138 +++++++- packages/contracts/src/keybindings.test.ts | 19 +- packages/contracts/src/keybindings.ts | 21 ++ 10 files changed, 713 insertions(+), 81 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 846c3778b0..9cf0394142 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -165,6 +165,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("ships configurable thread navigation defaults", () => + Effect.sync(() => { + const defaultsByCommand = new Map( + DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), + ); + + assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); + 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"); + }), + ); + it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 58363d2138..176e0300ad 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -15,6 +15,7 @@ import { MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, + THREAD_JUMP_KEYBINDING_COMMANDS, type ServerConfigIssue, } from "@t3tools/contracts"; import { Mutable } from "effect/Types"; @@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+shift+[", command: "thread.previous" }, + { key: "mod+shift+]", command: "thread.next" }, + ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + })), ]; function normalizeKeyToken(token: string): string { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e572f3c470..fbd332354a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; + const terminalShortcutLabelOptions = useMemo( + () => ({ + context: { + terminalFocus: true, + terminalOpen: Boolean(terminalState.terminalOpen), + }, + }), + [terminalState.terminalOpen], + ); + const nonTerminalShortcutLabelOptions = useMemo( + () => ({ + context: { + terminalFocus: false, + terminalOpen: Boolean(terminalState.terminalOpen), + }, + }), + [terminalState.terminalOpen], + ); const terminalToggleShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.toggle"), [keybindings], ); const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.split"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], ); const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], ); const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], ); const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), + [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { void navigate({ diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index cc49a82e69..b54ec1cb93 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + getVisibleSidebarThreadIds, + resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, @@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("resolveAdjacentThreadId", () => { + it("resolves adjacent thread ids in ordered sidebar traversal", () => { + const threads = [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ]; + + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: threads[1] ?? null, + direction: "previous", + }), + ).toBe(threads[0]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: threads[1] ?? null, + direction: "next", + }), + ).toBe(threads[2]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: null, + direction: "next", + }), + ).toBe(threads[0]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: null, + direction: "previous", + }), + ).toBe(threads[2]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: threads[0] ?? null, + direction: "previous", + }), + ).toBeNull(); + }); +}); + +describe("getVisibleSidebarThreadIds", () => { + it("returns only the rendered visible thread order across projects", () => { + expect( + getVisibleSidebarThreadIds([ + { + renderedThreads: [ + { id: ThreadId.makeUnsafe("thread-12") }, + { id: ThreadId.makeUnsafe("thread-11") }, + { id: ThreadId.makeUnsafe("thread-10") }, + ], + }, + { + renderedThreads: [ + { id: ThreadId.makeUnsafe("thread-8") }, + { id: ThreadId.makeUnsafe("thread-6") }, + ], + }, + ]), + ).toEqual([ + ThreadId.makeUnsafe("thread-12"), + ThreadId.makeUnsafe("thread-11"), + ThreadId.makeUnsafe("thread-10"), + ThreadId.makeUnsafe("thread-8"), + ThreadId.makeUnsafe("thread-6"), + ]); + }); +}); + describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 22a6268ac7..1e0871e0d2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -17,6 +17,8 @@ type SidebarProject = { }; type SidebarThreadSortInput = Pick; +export type ThreadTraversalDirection = "previous" | "next"; + export interface ThreadStatusPill { label: | "Working" @@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function getVisibleSidebarThreadIds( + renderedProjects: readonly { + renderedThreads: readonly { + id: TThreadId; + }[]; + }[], +): TThreadId[] { + return renderedProjects.flatMap((renderedProject) => + renderedProject.renderedThreads.map((thread) => thread.id), + ); +} + +export function resolveAdjacentThreadId(input: { + threadIds: readonly T[]; + currentThreadId: T | null; + direction: ThreadTraversalDirection; +}): T | null { + const { currentThreadId, direction, threadIds } = input; + + if (threadIds.length === 0) { + return null; + } + + if (currentThreadId === null) { + return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null); + } + + const currentIndex = threadIds.indexOf(currentThreadId); + if (currentIndex === -1) { + return null; + } + + if (direction === "previous") { + return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null; + } + + return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; +} + export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d6ef9fc819..89640ed5d2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -53,9 +53,17 @@ import { } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { shortcutLabelForCommand } from "../keybindings"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -100,7 +108,9 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + getVisibleSidebarThreadIds, getVisibleThreadsForProject, + resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, @@ -353,6 +363,7 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -366,12 +377,26 @@ export default function Sidebar() { const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); + const routeTerminalOpen = routeThreadId + ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen + : false; + const sidebarShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + }, + }), + [platform, routeTerminalOpen], + ); const threadGitTargets = useMemo( () => threads.map((thread) => ({ @@ -823,6 +848,20 @@ export default function Sidebar() { ], ); + const navigateToThread = useCallback( + (threadId: ThreadId) => { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(threadId); + void navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], + ); + const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -979,45 +1018,201 @@ export default function Sidebar() { [appSettings.sidebarProjectSortOrder, projects, visibleThreads], ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; + const renderedProjects = useMemo( + () => + sortedProjects.map((project) => { + const projectThreads = sortThreadsForSidebar( + visibleThreads.filter((thread) => thread.projectId === project.id), + appSettings.sidebarThreadSortOrder, + ); + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ), + ); + const activeThreadId = routeThreadId ?? undefined; + const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const pinnedCollapsedThread = + !project.expanded && activeThreadId + ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + : null; + const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; + const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = + getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadId, + isThreadListExpanded, + previewLimit: THREAD_PREVIEW_LIMIT, + }); + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : visibleProjectThreads; + + return { + hasHiddenThreads, + orderedProjectThreadIds, + project, + projectStatus, + projectThreads, + renderedThreads, + shouldShowThreadPanel, + isThreadListExpanded, + }; + }), + [ + appSettings.sidebarThreadSortOrder, + expandedThreadListsByProject, + routeThreadId, + sortedProjects, + visibleThreads, + ], + ); + const threadJumpCommandById = useMemo(() => { + const mapping = new Map>>(); + let visibleThreadIndex = 0; + + for (const renderedProject of renderedProjects) { + for (const thread of renderedProject.renderedThreads) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(thread.id, jumpCommand); + visibleThreadIndex += 1; + } + } + + return mapping; + }, [renderedProjects]); + const threadJumpThreadIds = useMemo( + () => [...threadJumpCommandById.keys()], + [threadJumpCommandById], + ); + const threadJumpLabelById = useMemo(() => { + const mapping = new Map(); + for (const [threadId, command] of threadJumpCommandById) { + const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); + if (label) { + mapping.set(threadId, label); + } + } + return mapping; + }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); + const orderedSidebarThreadIds = useMemo( + () => getVisibleSidebarThreadIds(renderedProjects), + [renderedProjects], + ); + + useEffect(() => { + const getShortcutContext = () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + }); + + const onWindowKeyDown = (event: KeyboardEvent) => { + setShowThreadJumpHints( + shouldShowThreadJumpHints(event, keybindings, { + platform, + context: getShortcutContext(), + }), + ); + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: getShortcutContext(), + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadId = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadIds, + currentThreadId: routeThreadId, + direction: traversalDirection, + }); + if (!targetThreadId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThreadId); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadId = threadJumpThreadIds[jumpIndex]; + if (!targetThreadId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThreadId); + }; + + const onWindowKeyUp = (event: KeyboardEvent) => { + setShowThreadJumpHints( + shouldShowThreadJumpHints(event, keybindings, { + platform, + context: getShortcutContext(), + }), + ); + }; + + const onWindowBlur = () => { + setShowThreadJumpHints(false); + }; + + 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); + }; + }, [ + keybindings, + navigateToThread, + orderedSidebarThreadIds, + platform, + routeTerminalOpen, + routeThreadId, + threadJumpThreadIds, + ]); function renderProjectItem( - project: (typeof sortedProjects)[number], + renderedProject: (typeof renderedProjects)[number], dragHandleProps: SortableProjectHandleProps | null, ) { - const projectThreads = sortThreadsForSidebar( - visibleThreads.filter((thread) => thread.projectId === project.id), - appSettings.sidebarThreadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ), - ); - const activeThreadId = routeThreadId ?? undefined; - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) - : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; - const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject( - { - threads: projectThreads, - activeThreadId, - isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }, - ); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads; + const { + hasHiddenThreads, + orderedProjectThreadIds, + project, + projectStatus, + projectThreads, + renderedThreads, + shouldShowThreadPanel, + isThreadListExpanded, + } = renderedProject; const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; + const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ @@ -1057,14 +1252,7 @@ export default function Sidebar() { onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); + navigateToThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); @@ -1234,15 +1422,26 @@ export default function Sidebar() { ) ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - + <> + {showThreadJumpHints && jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} + )}
@@ -1490,8 +1689,8 @@ export default function Sidebar() { ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"); + shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1773,12 +1972,15 @@ export default function Sidebar() { > project.id)} + items={renderedProjects.map((renderedProject) => renderedProject.project.id)} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} + {renderedProjects.map((renderedProject) => ( + + {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} ))} @@ -1786,9 +1988,9 @@ export default function Sidebar() { ) : ( - {sortedProjects.map((project) => ( - - {renderProjectItem(project, null)} + {renderedProjects.map((renderedProject) => ( + + {renderProjectItem(renderedProject, null)} ))} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f8..eba0bd3b46 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -18,8 +18,12 @@ import { isTerminalSplitShortcut, isTerminalToggleShortcut, resolveShortcutCommand, + shouldShowThreadJumpHints, shortcutLabelForCommand, terminalNavigationShortcutData, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, type ShortcutEventLike, } from "./keybindings"; @@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, + { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, + { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, + { shortcut: modShortcut("1"), command: "thread.jump.1" }, + { shortcut: modShortcut("2"), command: "thread.jump.2" }, + { shortcut: modShortcut("3"), command: "thread.jump.3" }, ]); describe("isTerminalToggleShortcut", () => { @@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => { }); describe("shortcutLabelForCommand", () => { - it("returns the most recent binding label", () => { + it("returns the effective binding label", () => { const bindings = compile([ { shortcut: modShortcut("\\"), @@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => { }, ]); assert.strictEqual( - shortcutLabelForCommand(bindings, "terminal.split", "Linux"), + shortcutLabelForCommand(bindings, "terminal.split", { + platform: "Linux", + context: { terminalFocus: false }, + }), "Ctrl+Shift+\\", ); }); - it("returns labels for non-terminal commands", () => { + it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.jump.3", "MacIntel"), + "⌘3", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), + "Ctrl+Shift+[", + ); + }); + + it("returns null for commands shadowed by a later conflicting shortcut", () => { + const bindings = compile([ + { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.1" }, + { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.7" }, + ]); + + assert.isNull(shortcutLabelForCommand(bindings, "thread.jump.1", "MacIntel")); + assert.strictEqual(shortcutLabelForCommand(bindings, "thread.jump.7", "MacIntel"), "⇧⌘1"); + }); + + it("respects when-context while resolving labels", () => { + const bindings = compile([ + { shortcut: modShortcut("d"), command: "diff.toggle" }, + { + shortcut: modShortcut("d"), + command: "terminal.split", + whenAst: whenIdentifier("terminalFocus"), + }, + ]); + + assert.strictEqual( + shortcutLabelForCommand(bindings, "diff.toggle", { + platform: "Linux", + context: { terminalFocus: false }, + }), + "Ctrl+D", + ); + assert.isNull( + shortcutLabelForCommand(bindings, "diff.toggle", { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + assert.strictEqual( + shortcutLabelForCommand(bindings, "terminal.split", { + platform: "Linux", + context: { terminalFocus: true }, + }), + "Ctrl+D", + ); + }); +}); + +describe("thread navigation helpers", () => { + it("maps jump commands to visible thread indices", () => { + assert.strictEqual(threadJumpCommandForIndex(0), "thread.jump.1"); + assert.strictEqual(threadJumpCommandForIndex(2), "thread.jump.3"); + assert.isNull(threadJumpCommandForIndex(9)); + assert.strictEqual(threadJumpIndexFromCommand("thread.jump.1"), 0); + assert.strictEqual(threadJumpIndexFromCommand("thread.jump.3"), 2); + assert.isNull(threadJumpIndexFromCommand("thread.next")); + }); + + it("maps traversal commands to directions", () => { + assert.strictEqual(threadTraversalDirectionFromCommand("thread.previous"), "previous"); + assert.strictEqual(threadTraversalDirectionFromCommand("thread.next"), "next"); + assert.isNull(threadTraversalDirectionFromCommand("thread.jump.1")); + assert.isNull(threadTraversalDirectionFromCommand(null)); + }); + + it("shows jump hints only when configured modifiers match", () => { + assert.isTrue( + shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isFalse( + shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isTrue( + shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + ); }); }); @@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => { "script.setup.run", ); }); + + it("matches bracket shortcuts using the physical key code", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "{", code: "BracketLeft", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + "thread.previous", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + "thread.next", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..286454dc05 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -3,11 +3,14 @@ import { type KeybindingShortcut, type KeybindingWhenNode, type ResolvedKeybindingsConfig, + THREAD_JUMP_KEYBINDING_COMMANDS, + type ThreadJumpKeybindingCommand, } from "@t3tools/contracts"; import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { type?: string; + code?: string; key: string; metaKey: boolean; ctrlKey: boolean; @@ -26,10 +29,28 @@ interface ShortcutMatchOptions { context?: Partial; } +interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions { + platform?: string; +} + const TERMINAL_WORD_BACKWARD = "\u001bb"; const TERMINAL_WORD_FORWARD = "\u001bf"; const TERMINAL_LINE_START = "\u0001"; const TERMINAL_LINE_END = "\u0005"; +const EVENT_CODE_KEY_ALIASES: Readonly> = { + BracketLeft: ["["], + BracketRight: ["]"], + Digit0: ["0"], + Digit1: ["1"], + Digit2: ["2"], + Digit3: ["3"], + Digit4: ["4"], + Digit5: ["5"], + Digit6: ["6"], + Digit7: ["7"], + Digit8: ["8"], + Digit9: ["9"], +}; function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); @@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string { return normalized; } -function matchesShortcut( +function resolveEventKeys(event: ShortcutEventLike): Set { + const keys = new Set([normalizeEventKey(event.key)]); + const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; + if (!aliases) return keys; + + for (const alias of aliases) { + keys.add(alias); + } + return keys; +} + +function matchesShortcutModifiers( event: ShortcutEventLike, shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { - const key = normalizeEventKey(event.key); - if (key !== shortcut.key) return false; - const useMetaForMod = isMacPlatform(platform); const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); @@ -56,6 +85,15 @@ function matchesShortcut( ); } +function matchesShortcut( + event: ShortcutEventLike, + shortcut: KeybindingShortcut, + platform = navigator.platform, +): boolean { + if (!matchesShortcutModifiers(event, shortcut, platform)) return false; + return resolveEventKeys(event).has(shortcut.key); +} + function resolvePlatform(options: ShortcutMatchOptions | undefined): string { return options?.platform ?? navigator.platform; } @@ -91,6 +129,48 @@ function matchesWhenClause( return evaluateWhenNode(whenAst, context); } +function shortcutConflictKey(shortcut: KeybindingShortcut, platform = navigator.platform): string { + const useMetaForMod = isMacPlatform(platform); + const metaKey = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); + + return [ + shortcut.key, + metaKey ? "meta" : "", + ctrlKey ? "ctrl" : "", + shortcut.shiftKey ? "shift" : "", + shortcut.altKey ? "alt" : "", + ].join("|"); +} + +function findEffectiveShortcutForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): KeybindingShortcut | null { + const platform = resolvePlatform(options); + const context = resolveContext(options); + const claimedShortcuts = new Set(); + + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding) continue; + if (!matchesWhenClause(binding.whenAst, context)) continue; + + const conflictKey = shortcutConflictKey(binding.shortcut, platform); + if (claimedShortcuts.has(conflictKey)) { + continue; + } + + claimedShortcuts.add(conflictKey); + if (binding.command === command) { + return binding.shortcut; + } + } + + return null; +} + function matchesCommandShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -104,7 +184,7 @@ export function resolveShortcutCommand( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, -): string | null { +): KeybindingCommand | null { const platform = resolvePlatform(options); const context = resolveContext(options); @@ -156,16 +236,52 @@ export function formatShortcutLabel( export function shortcutLabelForCommand( keybindings: ResolvedKeybindingsConfig, command: KeybindingCommand, - platform = navigator.platform, + options?: string | ResolvedShortcutLabelOptions, ): string | null { - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; - if (!binding || binding.command !== command) continue; - return formatShortcutLabel(binding.shortcut, platform); - } + const resolvedOptions = + typeof options === "string" + ? ({ platform: options } satisfies ResolvedShortcutLabelOptions) + : options; + const platform = resolvePlatform(resolvedOptions); + const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions); + return shortcut ? formatShortcutLabel(shortcut, platform) : null; +} + +export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null { + return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null; +} + +export function threadJumpIndexFromCommand(command: string): number | null { + const index = THREAD_JUMP_KEYBINDING_COMMANDS.indexOf(command as ThreadJumpKeybindingCommand); + return index === -1 ? null : index; +} + +export function threadTraversalDirectionFromCommand( + command: string | null, +): "previous" | "next" | null { + if (command === "thread.previous") return "previous"; + if (command === "thread.next") return "next"; return null; } +export function shouldShowThreadJumpHints( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + const platform = resolvePlatform(options); + + for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { + const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); + if (!shortcut) continue; + if (matchesShortcutModifiers(event, shortcut, platform)) { + return true; + } + } + + return false; +} + export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c53..c3a7d9f00e 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () => command: "chat.newLocal", }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + + const parsedThreadPrevious = yield* decode(KeybindingRule, { + key: "mod+shift+[", + command: "thread.previous", + }); + assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); }), ); @@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () => modKey: true, }, }, + { + command: "thread.jump.3", + shortcut: { + key: "3", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, ]); - assert.lengthOf(parsed, 1); + assert.lengthOf(parsed, 2); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b1824..067cba8804 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; +export const THREAD_JUMP_KEYBINDING_COMMANDS = [ + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9", +] as const; +export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; + +export const THREAD_KEYBINDING_COMMANDS = [ + "thread.previous", + "thread.next", + ...THREAD_JUMP_KEYBINDING_COMMANDS, +] as const; +export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; + const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", @@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + ...THREAD_KEYBINDING_COMMANDS, ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ From afc807ac3f8cc827d5f30f587a599dd077f244ff Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 18:28:52 -0700 Subject: [PATCH 32/45] Add new GitHub users to VOUCHED.td (#1508) --- .github/VOUCHED.td | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 72f270dca3..5535d54a5b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -28,4 +28,5 @@ github:realAhmedRoach github:shiroyasha9 github:Yash-Singh1 github:eggfriedrice24 -github:Ymit24 \ No newline at end of file +github:Ymit24 +github:shivamhwp From 9e60597174dcd69ae405d3966a374213c745388a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Mar 2026 20:50:23 -0700 Subject: [PATCH 33/45] Truncate oversized git diffs instead of failing (#1499) --- .../Layers/CheckpointStore.test.ts | 122 ++++++++++++++++++ apps/server/src/git/Layers/GitCore.test.ts | 40 ++++++ apps/server/src/git/Layers/GitCore.ts | 95 +++++++++++--- apps/server/src/git/Services/GitCore.ts | 1 + 4 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/checkpointing/Layers/CheckpointStore.test.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts new file mode 100644 index 0000000000..c430dbbde0 --- /dev/null +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -0,0 +1,122 @@ +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import { describe, expect } from "vitest"; + +import { checkpointRefForThreadTurn } from "../Utils.ts"; +import { CheckpointStoreLive } from "./CheckpointStore.ts"; +import { CheckpointStore } from "../Services/CheckpointStore.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { GitCommandError } from "../../git/Errors.ts"; +import { ServerConfig } from "../../config.ts"; +import { ThreadId } from "@t3tools/contracts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-checkpoint-store-test-", +}); +const GitCoreTestLayer = GitCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(NodeServices.layer), +); +const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( + Layer.provide(GitCoreTestLayer), + Layer.provide(NodeServices.layer), +); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, CheckpointStoreTestLayer); + +function makeTmpDir( + prefix = "checkpoint-store-test-", +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +function writeTextFile( + filePath: string, + contents: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); +} + +function git( + cwd: string, + args: ReadonlyArray, +): Effect.Effect { + return Effect.gen(function* () { + const gitCore = yield* GitCore; + const result = yield* gitCore.execute({ + operation: "CheckpointStore.test.git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); +} + +function initRepoWithCommit( + cwd: string, +): Effect.Effect< + void, + GitCommandError | PlatformError.PlatformError, + GitCore | FileSystem.FileSystem +> { + return Effect.gen(function* () { + const core = yield* GitCore; + yield* core.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); +} + +function buildLargeText(lineCount = 20_000): string { + return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) + .join("\n") + .concat("\n"); +} + +it.layer(TestLayer)("CheckpointStoreLive", (it) => { + describe("diffCheckpoints", () => { + it.effect("returns full oversized checkpoint diffs without truncation", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + }); + + expect(diff).toContain("diff --git"); + expect(diff).not.toContain("[truncated]"); + expect(diff).toContain("+line 19999"); + }), + ); + }); +}); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index dc97b93649..547a69e7e1 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -132,6 +132,12 @@ function commitWithDate( }); } +function buildLargeText(lineCount = 20_000): string { + return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) + .join("\n") + .concat("\n"); +} + // ── Tests ── it.layer(TestLayer)("git integration", (it) => { @@ -1670,6 +1676,40 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); + + const context = yield* core.prepareCommitContext(tmp); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("README.md"); + expect(context!.stagedPatch).toContain("[truncated]"); + }), + ); + + it.effect("readRangeContext truncates oversized diff patches instead of failing", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" }); + yield* writeTextFile(path.join(tmp, "large.txt"), buildLargeText()); + yield* git(tmp, ["add", "large.txt"]); + yield* git(tmp, ["commit", "-m", "Add large range context"]); + + const rangeContext = yield* core.readRangeContext(tmp, initialBranch); + expect(rangeContext.commitSummary).toContain("Add large range context"); + expect(rangeContext.diffSummary).toContain("large.txt"); + expect(rangeContext.diffPatch).toContain("[truncated]"); + }), + ); + it.effect("pushes with upstream setup and then skips when up to date", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 8bb5844228..fcb2f9c58e 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -32,6 +32,11 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; +const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; @@ -53,6 +58,8 @@ interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; + maxOutputBytes?: number | undefined; + truncateOutputAtMaxBytes?: boolean | undefined; progress?: ExecuteGitProgress | undefined; } @@ -439,12 +446,14 @@ const collectOutput = Effect.fn(function* ( input: Pick, stream: Stream.Stream, maxOutputBytes: number, + truncateOutputAtMaxBytes: boolean, onLine: ((line: string) => Effect.Effect) | undefined, ): Effect.fn.Return { const decoder = new TextDecoder(); let bytes = 0; let text = ""; let lineBuffer = ""; + let truncated = false; const emitCompleteLines = (flush: boolean) => Effect.gen(function* () { @@ -469,8 +478,11 @@ const collectOutput = Effect.fn(function* ( yield* Stream.runForEach(stream, (chunk) => Effect.gen(function* () { - bytes += chunk.byteLength; - if (bytes > maxOutputBytes) { + if (truncateOutputAtMaxBytes && truncated) { + return; + } + const nextBytes = bytes + chunk.byteLength; + if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { return yield* new GitCommandError({ operation: input.operation, command: quoteGitCommand(input.args), @@ -478,18 +490,26 @@ const collectOutput = Effect.fn(function* ( detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, }); } - const decoded = decoder.decode(chunk, { stream: true }); + + const chunkToDecode = + truncateOutputAtMaxBytes && nextBytes > maxOutputBytes + ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) + : chunk; + bytes += chunkToDecode.byteLength; + truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; + + const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); text += decoded; lineBuffer += decoded; yield* emitCompleteLines(false); }), ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); - const remainder = decoder.decode(); + const remainder = truncated ? "" : decoder.decode(); text += remainder; lineBuffer += remainder; yield* emitCompleteLines(true); - return text; + return truncated ? `${text}${OUTPUT_TRUNCATED_MARKER}` : text; }); export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) => @@ -511,6 +531,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" } as const; const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; const commandEffect = Effect.gen(function* () { const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( @@ -537,12 +558,14 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" commandInput, child.stdout, maxOutputBytes, + truncateOutputAtMaxBytes, input.progress?.onStdoutLine, ), collectOutput( commandInput, child.stderr, maxOutputBytes, + truncateOutputAtMaxBytes, input.progress?.onStderrLine, ), child.exitCode.pipe( @@ -603,6 +626,10 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" args, allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + : {}), ...(options.progress ? { progress: options.progress } : {}), }).pipe( Effect.flatMap((result) => { @@ -647,6 +674,14 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" Effect.map((result) => result.stdout), ); + const runGitStdoutWithOptions = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, options).pipe(Effect.map((result) => result.stdout)); + const branchExists = (cwd: string, branch: string): Effect.Effect => executeGit( "GitCore.branchExists", @@ -1162,12 +1197,15 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" return null; } - const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ - "diff", - "--cached", - "--patch", - "--minimal", - ]); + const stagedPatch = yield* runGitStdoutWithOptions( + "GitCore.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); return { stagedSummary, @@ -1363,14 +1401,33 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" const range = `${baseBranch}..HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ - runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), - runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), - runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ - "diff", - "--patch", - "--minimal", - range, - ]), + runGitStdoutWithOptions( + "GitCore.readRangeContext.log", + cwd, + ["log", "--oneline", range], + { + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitCore.readRangeContext.diffStat", + cwd, + ["diff", "--stat", range], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitCore.readRangeContext.diffPatch", + cwd, + ["diff", "--patch", "--minimal", range], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), ], { concurrency: "unbounded" }, ); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index b74c526897..f1a4e065cd 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -32,6 +32,7 @@ export interface ExecuteGitInput { readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; readonly progress?: ExecuteGitProgress; } From f82bae16680206e4be20baf3d835941aa68611cf Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 29 Mar 2026 09:53:10 +0530 Subject: [PATCH 34/45] Update system overhaul (#1505) Co-authored-by: Jono Kemball Co-authored-by: Ariaj Sarkar Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/desktop/src/main.ts | 25 ++- apps/desktop/src/preload.ts | 2 + apps/web/src/components/Sidebar.tsx | 55 +----- .../components/desktopUpdate.logic.test.ts | 115 ++++++++++-- .../web/src/components/desktopUpdate.logic.ts | 28 ++- .../components/settings/SettingsPanels.tsx | 158 +++++++++++++++- .../settings/SettingsSidebarNav.tsx | 2 +- .../components/sidebar/SidebarUpdatePill.tsx | 176 ++++++++++++++++++ .../src/lib/desktopUpdateReactQuery.test.ts | 49 +++++ apps/web/src/lib/desktopUpdateReactQuery.ts | 42 +++++ packages/contracts/src/ipc.ts | 6 + 11 files changed, 575 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarUpdatePill.tsx create mode 100644 apps/web/src/lib/desktopUpdateReactQuery.test.ts create mode 100644 apps/web/src/lib/desktopUpdateReactQuery.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 81a02d446c..f1086e9c29 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import type { DesktopTheme, DesktopUpdateActionResult, + DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); @@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean { ); } -async function checkForUpdates(reason: string): Promise { - if (isQuitting || !updaterConfigured || updateCheckInFlight) return; +async function checkForUpdates(reason: string): Promise { + if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; if (updateState.status === "downloading" || updateState.status === "downloaded") { console.info( `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, ); - return; + return false; } updateCheckInFlight = true; setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); @@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise { try { await autoUpdater.checkForUpdates(); + return true; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); setUpdateState( reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), ); console.error(`[desktop-updater] Failed to check for updates: ${message}`); + return true; } finally { updateCheckInFlight = false; } @@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void { state: updateState, } satisfies DesktopUpdateActionResult; }); + + ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); + ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { + if (!updaterConfigured) { + return { + checked: false, + state: updateState, + } satisfies DesktopUpdateCheckResult; + } + const checked = await checkForUpdates("web-ui"); + return { + checked, + state: updateState, + } satisfies DesktopUpdateCheckResult; + }); } function getIconOption(): { icon: string } | Record { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2fb7e3a1db..3d59db1714 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; @@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), + checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), onUpdateState: (listener) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89640ed5d2..100d0e3f47 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,7 +5,6 @@ import { FolderIcon, GitPullRequestIcon, PlusIcon, - RocketIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, - getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldHighlightDesktopUpdateError, - shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; @@ -120,6 +117,7 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; +import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -1661,12 +1659,6 @@ export default function Sidebar() { }; }, []); - const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); - - const desktopUpdateTooltip = desktopUpdateState - ? getDesktopUpdateButtonTooltip(desktopUpdateState) - : "Update available"; - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) @@ -1677,17 +1669,6 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled - ? "cursor-not-allowed opacity-60" - : "hover:bg-accent hover:text-foreground"; - const desktopUpdateButtonClasses = - desktopUpdateState?.status === "downloaded" - ? "text-emerald-500" - : desktopUpdateState?.status === "downloading" - ? "text-sky-400" - : shouldHighlightDesktopUpdateError(desktopUpdateState) - ? "text-rose-500 animate-pulse" - : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); @@ -1728,6 +1709,10 @@ export default function Sidebar() { } if (desktopUpdateButtonAction === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), + ); + if (!confirmed) return; void bridge .installUpdate() .then((result) => { @@ -1795,30 +1780,9 @@ export default function Sidebar() { return ( <> {isElectron ? ( - <> - - {wordmark} - {showDesktopUpdateButton && ( - - - - - } - /> - {desktopUpdateTooltip} - - )} - - + + {wordmark} + ) : ( {wordmark} @@ -2006,6 +1970,7 @@ export default function Sidebar() { + { expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); + it("prefers install when a downloaded version already exists", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "available", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }; + expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); + }); + it("hides the button for non-actionable check errors", () => { const state: DesktopUpdateState = { ...baseState, @@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => { ).toBe(false); }); - it("highlights only actionable updater errors", () => { - expect( - shouldHighlightDesktopUpdateError({ - ...baseState, - status: "error", - errorContext: "download", - canRetry: true, - }), - ).toBe(true); - expect( - shouldHighlightDesktopUpdateError({ - ...baseState, - status: "error", - errorContext: "check", - canRetry: true, - }), - ).toBe(false); - }); - it("shows an Apple Silicon warning for Intel builds under Rosetta", () => { const state: DesktopUpdateState = { ...baseState, @@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => { expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); }); + + it("includes the downloaded version in the install confirmation copy", () => { + expect( + getDesktopUpdateInstallConfirmationMessage({ + availableVersion: "1.1.0", + downloadedVersion: "1.1.1", + }), + ).toContain("Install update 1.1.1 and restart T3 Code?"); + }); + + it("falls back to generic install confirmation copy when no version is available", () => { + expect( + getDesktopUpdateInstallConfirmationMessage({ + availableVersion: null, + downloadedVersion: null, + }), + ).toContain("Install update and restart T3 Code?"); + }); +}); + +describe("canCheckForUpdate", () => { + it("returns false for null state", () => { + expect(canCheckForUpdate(null)).toBe(false); + }); + + it("returns false when updates are disabled", () => { + expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false); + }); + + it("returns false while checking", () => { + expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false); + }); + + it("returns false while downloading", () => { + expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe( + false, + ); + }); + + it("returns false once an update has been downloaded", () => { + expect( + canCheckForUpdate({ + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }), + ).toBe(false); + }); + + it("returns true when idle", () => { + expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); + }); + + it("returns true when up-to-date", () => { + expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); + }); + + it("returns true when an update is available", () => { + expect( + canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), + ).toBe(true); + }); + + it("returns true on error so the user can retry", () => { + expect( + canCheckForUpdate({ + ...baseState, + status: "error", + errorContext: "check", + message: "network", + }), + ).toBe(true); + }); +}); + +describe("getDesktopUpdateButtonTooltip", () => { + it("returns 'Up to date' for non-actionable states", () => { + expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date"); + expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe( + "Up to date", + ); + }); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 5c9c303026..38983c810b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none"; export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { + if (state.downloadedVersion) { + return "install"; + } if (state.status === "available") { return "download"; } - if (state.status === "downloaded") { - return "install"; - } if (state.status === "error") { - if (state.errorContext === "install" && state.downloadedVersion) { - return "install"; - } if (state.errorContext === "download" && state.availableVersion) { return "download"; } @@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string } return state.message ?? "Update failed"; } - return "Update available"; + return "Up to date"; +} + +export function getDesktopUpdateInstallConfirmationMessage( + state: Pick, +): string { + const version = state.downloadedVersion ?? state.availableVersion; + return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; } export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { @@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu if (!state || state.status !== "error") return false; return state.errorContext === "download" || state.errorContext === "install"; } + +export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { + if (!state || !state.enabled) return false; + return ( + state.status !== "checking" && + state.status !== "downloading" && + state.status !== "downloaded" && + state.status !== "disabled" + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bb149c00a4..f9fdb1d615 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -22,12 +22,24 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; +import { + canCheckForUpdate, + getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, +} from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; +import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; +import { + setDesktopUpdateStateQueryData, + useDesktopUpdateState, +} from "../../lib/desktopUpdateReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; import { MAX_CUSTOM_MODEL_LENGTH, @@ -236,7 +248,7 @@ function SettingsRow({ control, children, }: { - title: string; + title: ReactNode; description: string; status?: ReactNode; resetAction?: ReactNode; @@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { ); } +function AboutVersionTitle() { + return ( + + Version + {APP_VERSION} + + ); +} + +function AboutVersionSection() { + const queryClient = useQueryClient(); + const updateStateQuery = useDesktopUpdateState(); + + const updateState = updateStateQuery.data ?? null; + + const handleButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge) return; + + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not download update", + description: error instanceof Error ? error.message : "Download failed.", + }); + }); + return; + } + + if (action === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage( + updateState ?? { availableVersion: null, downloadedVersion: null }, + ), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }); + }); + return; + } + + if (typeof bridge.checkForUpdate !== "function") return; + void bridge + .checkForUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + if (!result.checked) { + toastManager.add({ + type: "error", + title: "Could not check for updates", + description: + result.state.message ?? "Automatic updates are not available in this build.", + }); + } + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not check for updates", + description: error instanceof Error ? error.message : "Update check failed.", + }); + }); + }, [queryClient, updateState]); + + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; + const buttonDisabled = + action === "none" + ? !canCheckForUpdate(updateState) + : isDesktopUpdateButtonDisabled(updateState); + + const actionLabel: Record = { download: "Download", install: "Install" }; + const statusLabel: Record = { + checking: "Checking…", + downloading: "Downloading…", + "up-to-date": "Up to Date", + }; + const buttonLabel = + actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; + const description = + action === "download" || action === "install" + ? "Update available." + : "Current version of the application."; + + return ( + } + description={description} + control={ + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} + + } + /> + ); +} + export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() { } /> + - {APP_VERSION}} - /> + + {isElectron ? ( + + ) : ( + } + description="Current version of the application." + /> + )} ); @@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() { {archivedGroups.length === 0 ? ( - + diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 20914b499d..6ba698c91f 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, - { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon }, + { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; export function SettingsSidebarNav({ pathname }: { pathname: string }) { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx new file mode 100644 index 0000000000..2f9aec112a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -0,0 +1,176 @@ +import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { isElectron } from "../../env"; +import { + setDesktopUpdateStateQueryData, + useDesktopUpdateState, +} from "../../lib/desktopUpdateReactQuery"; +import { toastManager } from "../ui/toast"; +import { + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldShowDesktopUpdateButton, + shouldToastDesktopUpdateActionResult, +} from "../desktopUpdate.logic"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function SidebarUpdatePill() { + const queryClient = useQueryClient(); + const state = useDesktopUpdateState().data ?? null; + const [dismissed, setDismissed] = useState(false); + + const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; + const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; + const disabled = isDesktopUpdateButtonDisabled(state); + const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; + + const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); + const arm64Description = + state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; + + const handleAction = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !state) return; + if (disabled || action === "none") return; + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not download update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + return; + } + + if (action === "install") { + const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state)); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not install update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + }, [action, disabled, queryClient, state]); + + if (!visible && !showArm64Warning) return null; + + return ( +
+ {showArm64Warning && arm64Description && ( + + + Intel build on Apple Silicon + {arm64Description} + + )} + {visible && ( +
+
+ + + {action === "install" ? ( + <> + + Restart to update + + ) : state?.status === "downloading" ? ( + <> + + + Downloading + {typeof state.downloadPercent === "number" + ? ` (${Math.floor(state.downloadPercent)}%)` + : "…"} + + + ) : ( + <> + + Update available + + )} + + } + /> + {tooltip} + + {action === "download" && ( + + setDismissed(true)} + > + + + } + /> + Dismiss until next launch + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/desktopUpdateReactQuery.test.ts b/apps/web/src/lib/desktopUpdateReactQuery.test.ts new file mode 100644 index 0000000000..a0f4755918 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.test.ts @@ -0,0 +1,49 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { + desktopUpdateQueryKeys, + desktopUpdateStateQueryOptions, + setDesktopUpdateStateQueryData, +} from "./desktopUpdateReactQuery"; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "idle", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +describe("desktopUpdateStateQueryOptions", () => { + it("always refetches on mount so Settings does not reuse stale desktop update state", () => { + const options = desktopUpdateStateQueryOptions(); + + expect(options.staleTime).toBe(Infinity); + expect(options.refetchOnMount).toBe("always"); + }); +}); + +describe("setDesktopUpdateStateQueryData", () => { + it("writes desktop update state into the shared cache key", () => { + const queryClient = new QueryClient(); + const nextState: DesktopUpdateState = { + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }; + + setDesktopUpdateStateQueryData(queryClient, nextState); + + expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState); + }); +}); diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts new file mode 100644 index 0000000000..9315772786 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.ts @@ -0,0 +1,42 @@ +import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { DesktopUpdateState } from "@t3tools/contracts"; + +export const desktopUpdateQueryKeys = { + all: ["desktop", "update"] as const, + state: () => ["desktop", "update", "state"] as const, +}; + +export const setDesktopUpdateStateQueryData = ( + queryClient: QueryClient, + state: DesktopUpdateState | null, +) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state); + +export function desktopUpdateStateQueryOptions() { + return queryOptions({ + queryKey: desktopUpdateQueryKeys.state(), + queryFn: async () => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.getUpdateState !== "function") return null; + return bridge.getUpdateState(); + }, + staleTime: Infinity, + refetchOnMount: "always", + }); +} + +export function useDesktopUpdateState() { + const queryClient = useQueryClient(); + const query = useQuery(desktopUpdateStateQueryOptions()); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.onUpdateState !== "function") return; + + return bridge.onUpdateState((nextState) => { + setDesktopUpdateStateQueryData(queryClient, nextState); + }); + }, [queryClient]); + + return query; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 0443128dd5..5585e7f309 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface DesktopUpdateCheckResult { + checked: boolean; + state: DesktopUpdateState; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -113,6 +118,7 @@ export interface DesktopBridge { openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; + checkForUpdate: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; From 5462d501774a25b8cc16a572d6e6b44dc54bc796 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:52:14 +0000 Subject: [PATCH 35/45] chore(release): prepare v0.0.15 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- bun.lock | 8 ++++---- packages/contracts/package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 188e701e7e..7ecb81c791 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.14", + "version": "0.0.15", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index ea818b7d3e..e473e21fd1 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.14", + "version": "0.0.15", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 5127faf827..bd0fb9e0ac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.14", + "version": "0.0.15", "private": true, "type": "module", "scripts": { diff --git a/bun.lock b/bun.lock index fa083cdc53..e13245b727 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.14", + "version": "0.0.15", "dependencies": { "effect": "catalog:", "electron": "40.6.0", @@ -43,7 +43,7 @@ }, "apps/server": { "name": "t3", - "version": "0.0.14", + "version": "0.0.15", "bin": { "t3": "./dist/index.mjs", }, @@ -73,7 +73,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.14", + "version": "0.0.15", "dependencies": { "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", @@ -124,7 +124,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.14", + "version": "0.0.15", "dependencies": { "effect": "catalog:", }, diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 29d0c1398f..fe03c205a5 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.0.14", + "version": "0.0.15", "private": true, "files": [ "dist" From 6e587ca49c639222f97d9d4f17a88458a6e7b0b7 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 11:19:07 +0530 Subject: [PATCH 36/45] Integrate upstream runtime sync foundations --- apps/server/src/codexAppServerManager.test.ts | 36 ++ apps/server/src/codexAppServerManager.ts | 18 +- .../src/provider/Layers/CodexAdapter.test.ts | 34 ++ .../src/provider/Layers/CodexAdapter.ts | 13 + .../provider/Layers/ProviderRegistry.test.ts | 50 +++ .../src/provider/Layers/ProviderRegistry.ts | 424 ++++++++++++++++-- apps/web/src/appSettings.ts | 195 +++++++- 7 files changed, 728 insertions(+), 42 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index a1a966491f..3a14e51b1b 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -236,6 +236,42 @@ describe("classifyCodexStderrLine", () => { }); }); +describe("process stderr events", () => { + it("emits classified stderr lines as notifications", () => { + const manager = new CodexAppServerManager(); + const emitEvent = vi + .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent") + .mockImplementation(() => {}); + + ( + manager as unknown as { + emitNotificationEvent: ( + context: { session: { threadId: ThreadId } }, + method: string, + message: string, + ) => void; + } + ).emitNotificationEvent( + { + session: { + threadId: asThreadId("thread-1"), + }, + }, + "process/stderr", + "fatal: permission denied", + ); + + expect(emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "notification", + method: "process/stderr", + threadId: "thread-1", + message: "fatal: permission denied", + }), + ); + }); +}); + describe("normalizeCodexModelSlug", () => { it("maps 5.3 aliases to gpt-5.3-codex", () => { expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index dd5cb45dcf..ab3d52f934 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1097,7 +1097,7 @@ export class CodexAppServerManager extends EventEmitter { }), ); + it.effect("maps process stderr notifications to runtime.warning", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-process-stderr"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "process/stderr", + turnId: asTurnId("turn-1"), + message: "The filename or extension is too long. (os error 206)", + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "runtime.warning"); + if (firstEvent.value.type !== "runtime.warning") { + return; + } + assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal( + firstEvent.value.payload.message, + "The filename or extension is too long. (os error 206)", + ); + }), + ); + it.effect("preserves request type when mapping serverRequest/resolved", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index b4d3a229b8..1fdfc3d507 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1233,6 +1233,19 @@ function mapToRuntimeEvents( ]; } + if (event.method === "process/stderr") { + return [ + { + type: "runtime.warning", + ...runtimeEventBase(event, canonicalThreadId), + payload: { + message: event.message ?? "Codex process stderr", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + if (event.method === "windowsSandbox/setupCompleted") { const payloadRecord = asObject(event.payload); const success = payloadRecord?.success; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index bed25977d6..21a541989d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -448,6 +448,56 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + it.effect("returns snapshots for all supported providers", () => + Effect.gen(function* () { + const serverSettingsLayer = ServerSettingsService.layerTest(); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + if (command === "claude") { + return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "spawn ENOENT", code: 1 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: "Authenticated\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ); + const providers = yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + return yield* registry.getProviders; + }).pipe( + Effect.provide(providerRegistryLayer), + ); + + assert.deepStrictEqual( + providers.map((provider) => provider.provider), + [ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", + ], + ); + }), + ); + it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { const serverSettingsLayer = ServerSettingsService.layerTest({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 1e66ce8ff5..2b7c90ff42 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -1,24 +1,378 @@ /** - * ProviderRegistryLive - Aggregates provider-specific snapshot services. + * ProviderRegistryLive - Aggregates server-side provider snapshots. + * + * The fork supports more runtime adapters than upstream's original provider + * snapshot layer. This registry probes every supported provider so + * `server.getConfig` and `server.providersUpdated` stay complete. * * @module ProviderRegistryLive */ -import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; -import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; +import { execFile } from "node:child_process"; + +import { + MODEL_OPTIONS_BY_PROVIDER, + type ProviderKind, + type ServerSettings as ContractServerSettings, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { Cause, Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; +import { fetchAmpUsage } from "../../ampServerManager"; +import { fetchGeminiCliUsage } from "../../geminiCliServerManager"; +import { fetchKiloModels } from "../../kiloServerManager"; +import { fetchOpenCodeModels } from "../../opencodeServerManager"; +import { ServerSettingsService } from "../../serverSettings"; +import { fetchCopilotModels } from "./CopilotAdapter"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; +import { fetchCursorModels } from "./CursorAdapter"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, + type ProviderProbeResult, +} from "../providerSnapshot"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; -const loadProviders = ( - codexProvider: CodexProviderShape, - claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { +const ALL_PROVIDERS = [ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", +] as const satisfies ReadonlyArray; + +const PROVIDER_LABELS: Record = { + codex: "Codex", + copilot: "Copilot", + claudeAgent: "Claude", + cursor: "Cursor", + opencode: "OpenCode", + geminiCli: "Gemini CLI", + amp: "Amp", + kilo: "Kilo", +}; + +const toBuiltInServerProviderModel = ( + model: (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number], +): ServerProviderModel => ({ + slug: model.slug, + name: model.name, + isCustom: false, + capabilities: "capabilities" in model ? model.capabilities ?? null : null, +}); + +const BUILT_IN_MODELS_BY_PROVIDER = ALL_PROVIDERS.reduce( + (acc, provider) => { + acc[provider] = MODEL_OPTIONS_BY_PROVIDER[provider].map(toBuiltInServerProviderModel); + return acc; + }, + {} as Record>, +); + +type ProviderWithBinary = Exclude; +type ProviderSettingsShape = { + readonly enabled: boolean; + readonly customModels: ReadonlyArray; + readonly binaryPath?: string; +}; +class ProviderSnapshotProbeError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.name = "ProviderSnapshotProbeError"; + this.cause = cause; + } +} +type ProviderRegistryDeps = { + readonly getSettings: Effect.Effect; + readonly codexProvider: CodexProviderShape; + readonly claudeProvider: ClaudeProviderShape; +}; + +const trimToUndefined = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +}; + +const wrapProbeError = (cause: unknown) => new ProviderSnapshotProbeError(cause); +const unwrapProbeError = (error: unknown) => + error instanceof ProviderSnapshotProbeError ? error.cause : error; + +const runVersionCommand = async (binaryPath: string): Promise => { + const tryArgs = async (args: ReadonlyArray) => + new Promise((resolve, reject) => { + execFile( + binaryPath, + [...args], + { + env: process.env, + timeout: 4_000, + shell: process.platform === "win32", + }, + (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr, code: 0 }); + }, + ); + }); + + return tryArgs(["--version"]).catch(() => tryArgs(["version"])); +}; + +function mergeBuiltInAndDiscoveredModels( + provider: ProviderKind, + discoveredModels: ReadonlyArray<{ slug: string; name: string }>, +): ReadonlyArray { + const staticModels = BUILT_IN_MODELS_BY_PROVIDER[provider]; + const staticBySlug = new Map(staticModels.map((model) => [model.slug, model])); + const merged: ServerProviderModel[] = []; + const seen = new Set(); + + for (const model of discoveredModels) { + const existing = staticBySlug.get(model.slug); + merged.push({ + slug: model.slug, + name: model.name, + isCustom: false, + capabilities: existing?.capabilities ?? null, + }); + seen.add(model.slug); + } + + for (const model of staticModels) { + if (seen.has(model.slug)) { + continue; + } + merged.push(model); + } + + return merged; +} + +function buildDisabledSnapshot( + provider: ProviderKind, + settings: ProviderSettingsShape, + models: ReadonlyArray, +): ServerProvider { + return buildServerProvider({ + provider, + enabled: settings.enabled, + checkedAt: new Date().toISOString(), + models, + probe: { + installed: true, + version: null, + status: "ready", + authStatus: "unknown", + }, + }); +} + +function buildWarningSnapshot(input: { + provider: ProviderKind; + settings: ProviderSettingsShape; + models: ReadonlyArray; + installed: boolean; + version?: string | null; + message: string; +}): ServerProvider { + return buildServerProvider({ + provider: input.provider, + enabled: input.settings.enabled, + checkedAt: new Date().toISOString(), + models: input.models, + probe: { + installed: input.installed, + version: input.version ?? null, + status: "warning", + authStatus: "unknown", + message: input.message, + }, + }); +} + +function buildReadySnapshot(input: { + provider: ProviderKind; + settings: ProviderSettingsShape; + models: ReadonlyArray; + version?: string | null; + authStatus?: ProviderProbeResult["authStatus"]; + message?: string; +}): ServerProvider { + return buildServerProvider({ + provider: input.provider, + enabled: input.settings.enabled, + checkedAt: new Date().toISOString(), + models: input.models, + probe: { + installed: true, + version: input.version ?? null, + status: "ready", + authStatus: input.authStatus ?? "unknown", + ...(input.message ? { message: input.message } : {}), + }, + }); +} + +const runBinaryBackedSnapshot = ( + provider: ProviderWithBinary, + settings: ProviderSettingsShape, + fetchDiscoveredModels?: + | ((binaryPath: string | undefined) => Promise>) + | undefined, +) => + Effect.gen(function* () { + const discoveredModels = yield* Effect.tryPromise({ + try: () => fetchDiscoveredModels?.(trimToUndefined(settings.binaryPath)) ?? Promise.resolve([]), + catch: wrapProbeError, + }).pipe( + Effect.catchCause((cause) => { + const error = unwrapProbeError(Cause.squash(cause)); + if (isCommandMissingCause(error)) { + return Effect.succeed>([]); + } + return Effect.failCause(cause); + }), + ); + + const baseModels = + discoveredModels.length > 0 + ? mergeBuiltInAndDiscoveredModels(provider, discoveredModels) + : BUILT_IN_MODELS_BY_PROVIDER[provider]; + const models = providerModelsFromSettings(baseModels, provider, settings.customModels); + + if (!settings.enabled) { + return buildDisabledSnapshot(provider, settings, models); + } + + const binaryPath = trimToUndefined(settings.binaryPath); + if (!binaryPath && !fetchDiscoveredModels) { + return buildWarningSnapshot({ + provider, + settings, + models, + installed: true, + message: `${PROVIDER_LABELS[provider]} runtime is enabled, but installation status is not probed yet.`, + }); + } + + if (!binaryPath && discoveredModels.length === 0 && fetchDiscoveredModels) { + return buildWarningSnapshot({ + provider, + settings, + models, + installed: true, + message: `${PROVIDER_LABELS[provider]} is enabled, but the CLI did not report any models.`, + }); + } + + const versionProbe = binaryPath + ? yield* Effect.tryPromise({ + try: () => runVersionCommand(binaryPath), + catch: wrapProbeError, + }).pipe( + Effect.map((result) => parseGenericCliVersion(`${result.stdout}\n${result.stderr}`)), + Effect.catchCause((cause) => + isCommandMissingCause(unwrapProbeError(Cause.squash(cause))) + ? Effect.succeed(null) + : Effect.failCause(cause), + ), + ) + : null; + + return buildReadySnapshot({ + provider, + settings, + models, + version: versionProbe, + }); + }).pipe( + Effect.catchCause((cause) => { + const error = unwrapProbeError(Cause.squash(cause)); + const models = providerModelsFromSettings( + BUILT_IN_MODELS_BY_PROVIDER[provider], + provider, + settings.customModels, + ); + if (isCommandMissingCause(error)) { + return Effect.succeed( + buildWarningSnapshot({ + provider, + settings, + models, + installed: false, + message: `${PROVIDER_LABELS[provider]} CLI not found on PATH.`, + }), + ); + } + return Effect.succeed( + buildWarningSnapshot({ + provider, + settings, + models, + installed: true, + message: error instanceof Error ? error.message : `Could not probe ${PROVIDER_LABELS[provider]}.`, + }), + ); + }), + ); + +const loadProviderSnapshot = (deps: ProviderRegistryDeps, provider: ProviderKind) => + Effect.gen(function* () { + const settings = yield* deps.getSettings; + + switch (provider) { + case "codex": + return yield* deps.codexProvider.getSnapshot; + case "claudeAgent": + return yield* deps.claudeProvider.getSnapshot; + case "copilot": + return yield* runBinaryBackedSnapshot("copilot", settings.providers.copilot, (binaryPath) => + fetchCopilotModels(binaryPath).then((models) => + (models ?? []).map((model) => ({ slug: model.slug, name: model.name })), + ), + ); + case "cursor": + return yield* runBinaryBackedSnapshot("cursor", settings.providers.cursor, (binaryPath) => + fetchCursorModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + ); + case "opencode": + return yield* runBinaryBackedSnapshot( + "opencode", + settings.providers.opencode, + (binaryPath) => + fetchOpenCodeModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + ); + case "kilo": + return yield* runBinaryBackedSnapshot("kilo", settings.providers.kilo, (binaryPath) => + fetchKiloModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + ); + case "geminiCli": + void fetchGeminiCliUsage(); + return yield* runBinaryBackedSnapshot("geminiCli", settings.providers.geminiCli); + case "amp": + void fetchAmpUsage(); + return yield* runBinaryBackedSnapshot("amp", settings.providers.amp); + } + }); + +const loadProviders = (deps: ProviderRegistryDeps, providers: ReadonlyArray) => + Effect.forEach(providers, (provider) => loadProviderSnapshot(deps, provider), { concurrency: "unbounded", }); @@ -30,58 +384,60 @@ export const haveProvidersChanged = ( export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const deps: ProviderRegistryDeps = { + getSettings: serverSettings.getSettings.pipe(Effect.orDie), + codexProvider, + claudeProvider, + }; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders(deps, ALL_PROVIDERS), ); - const syncProviders = (options?: { readonly publish?: boolean }) => + const syncProviders = ( + providers: ReadonlyArray = ALL_PROVIDERS, + options?: { readonly publish?: boolean }, + ) => Effect.gen(function* () { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); - yield* Ref.set(providersRef, providers); + const previousByProvider = new Map( + previousProviders.map((provider) => [provider.provider, provider]), + ); + const nextSnapshots = yield* loadProviders(deps, providers); + const mergedProviders = ALL_PROVIDERS.map( + (provider) => + nextSnapshots.find((snapshot) => snapshot.provider === provider) ?? + previousByProvider.get(provider), + ).filter((provider): provider is ServerProvider => provider !== undefined); + yield* Ref.set(providersRef, mergedProviders); - if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { - yield* PubSub.publish(changesPubSub, providers); + if (options?.publish !== false && haveProvidersChanged(previousProviders, mergedProviders)) { + yield* PubSub.publish(changesPubSub, mergedProviders); } - return providers; + return mergedProviders; }); - yield* Stream.runForEach(codexProvider.streamChanges, () => syncProviders()).pipe( + yield* Stream.runForEach(serverSettings.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); - yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( + yield* Effect.forever(Effect.sleep("60 seconds").pipe(Effect.flatMap(() => syncProviders()))).pipe( Effect.forkScoped, ); return { - getProviders: syncProviders({ publish: false }).pipe( + getProviders: syncProviders(ALL_PROVIDERS, { publish: false }).pipe( Effect.tapError(Effect.logError), Effect.orElseSucceed(() => []), ), refresh: (provider?: ProviderKind) => - Effect.gen(function* () { - switch (provider) { - case "codex": - yield* codexProvider.refresh; - break; - case "claudeAgent": - yield* claudeProvider.refresh; - break; - default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); - break; - } - return yield* syncProviders(); - }).pipe( + syncProviders(provider ? [provider] : ALL_PROVIDERS).pipe( Effect.tapError(Effect.logError), Effect.orElseSucceed(() => []), ), diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5e6a63ff11..3448ac689a 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,8 +1,14 @@ import { useCallback, useMemo } from "react"; import { Option, Schema } from "effect"; -import type { ProviderStartOptions } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + type ProviderStartOptions, + type ProviderKind, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { DEFAULT_ACCENT_COLOR, isValidAccentColor, normalizeAccentColor } from "./accentColor"; import { useLocalStorage } from "./hooks/useLocalStorage"; +import { useSettings, useUpdateSettings } from "./hooks/useSettings"; // Domain modules import { @@ -55,6 +61,38 @@ export { } from "./gitTextGeneration"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +const APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS = { + codex: "customCodexModels", + copilot: "customCopilotModels", + claudeAgent: "customClaudeModels", + cursor: "customCursorModels", + opencode: "customOpencodeModels", + geminiCli: "customGeminiCliModels", + amp: "customAmpModels", + kilo: "customKiloModels", +} as const satisfies Record; +const MIRRORED_CLIENT_KEYS = new Set([ + "confirmThreadDelete", + "diffWordWrap", + "sidebarProjectSortOrder", + "sidebarThreadSortOrder", + "timestampFormat", +]); +const MIRRORED_SERVER_KEYS = new Set([ + "claudeBinaryPath", + "codexBinaryPath", + "codexHomePath", + "defaultThreadEnvMode", + "enableAssistantStreaming", + "customCodexModels", + "customCopilotModels", + "customClaudeModels", + "customCursorModels", + "customOpencodeModels", + "customGeminiCliModels", + "customAmpModels", + "customKiloModels", +]); const withDefaults = < @@ -222,6 +260,110 @@ function parsePersistedSettings(value: string | null): AppSettings { } } +function withUnifiedCompatSettings( + localSettings: AppSettings, + unifiedSettings: Pick< + UnifiedSettings, + | "confirmThreadDelete" + | "defaultThreadEnvMode" + | "diffWordWrap" + | "enableAssistantStreaming" + | "providers" + | "sidebarProjectSortOrder" + | "sidebarThreadSortOrder" + | "timestampFormat" + >, +): AppSettings { + return normalizeAppSettings({ + ...localSettings, + claudeBinaryPath: unifiedSettings.providers.claudeAgent.binaryPath, + codexBinaryPath: unifiedSettings.providers.codex.binaryPath, + codexHomePath: unifiedSettings.providers.codex.homePath, + defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, + confirmThreadDelete: unifiedSettings.confirmThreadDelete, + diffWordWrap: unifiedSettings.diffWordWrap, + enableAssistantStreaming: unifiedSettings.enableAssistantStreaming, + sidebarProjectSortOrder: unifiedSettings.sidebarProjectSortOrder, + sidebarThreadSortOrder: unifiedSettings.sidebarThreadSortOrder, + timestampFormat: unifiedSettings.timestampFormat, + customCodexModels: [...unifiedSettings.providers.codex.customModels], + customCopilotModels: [...unifiedSettings.providers.copilot.customModels], + customClaudeModels: [...unifiedSettings.providers.claudeAgent.customModels], + customCursorModels: [...unifiedSettings.providers.cursor.customModels], + customOpencodeModels: [...unifiedSettings.providers.opencode.customModels], + customGeminiCliModels: [...unifiedSettings.providers.geminiCli.customModels], + customAmpModels: [...unifiedSettings.providers.amp.customModels], + customKiloModels: [...unifiedSettings.providers.kilo.customModels], + }); +} + +function toUnifiedPatch(patch: Partial): Partial { + const providersPatch: Partial< + Record< + ProviderKind, + { + binaryPath?: string; + homePath?: string; + customModels?: ReadonlyArray; + } + > + > = {}; + if (patch.codexBinaryPath !== undefined || patch.codexHomePath !== undefined) { + providersPatch.codex = { + ...(patch.codexBinaryPath !== undefined ? { binaryPath: patch.codexBinaryPath } : {}), + ...(patch.codexHomePath !== undefined ? { homePath: patch.codexHomePath } : {}), + }; + } + if (patch.claudeBinaryPath !== undefined) { + providersPatch.claudeAgent = { + binaryPath: patch.claudeBinaryPath, + }; + } + const providerModelEntries = Object.entries(APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS) as Array< + [ProviderKind, (typeof APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS)[ProviderKind]] + >; + for (const [provider, settingsKey] of providerModelEntries) { + const models = patch[settingsKey]; + if (!Array.isArray(models)) { + continue; + } + providersPatch[provider] = { + ...(providersPatch[provider] ?? {}), + customModels: normalizeCustomModelSlugs(models, provider), + }; + } + return { + ...(patch.confirmThreadDelete !== undefined + ? { confirmThreadDelete: patch.confirmThreadDelete } + : {}), + ...(patch.diffWordWrap !== undefined ? { diffWordWrap: patch.diffWordWrap } : {}), + ...(patch.sidebarProjectSortOrder !== undefined + ? { sidebarProjectSortOrder: patch.sidebarProjectSortOrder } + : {}), + ...(patch.sidebarThreadSortOrder !== undefined + ? { sidebarThreadSortOrder: patch.sidebarThreadSortOrder } + : {}), + ...(patch.timestampFormat !== undefined ? { timestampFormat: patch.timestampFormat } : {}), + ...(patch.defaultThreadEnvMode !== undefined + ? { defaultThreadEnvMode: patch.defaultThreadEnvMode } + : {}), + ...(patch.enableAssistantStreaming !== undefined + ? { enableAssistantStreaming: patch.enableAssistantStreaming } + : {}), + ...(Object.keys(providersPatch).length > 0 + ? { providers: providersPatch as Partial } + : {}), + } as Partial; +} + +function stripMirroredKeys(patch: Partial): Partial { + const nextPatch = { ...patch }; + for (const key of [...MIRRORED_CLIENT_KEYS, ...MIRRORED_SERVER_KEYS]) { + delete nextPatch[key]; + } + return nextPatch; +} + export function getAppSettingsSnapshot(): AppSettings { if (typeof window === "undefined") { return DEFAULT_APP_SETTINGS; @@ -238,11 +380,39 @@ export function getAppSettingsSnapshot(): AppSettings { } export function useAppSettings() { - const [settings, setSettings] = useLocalStorage( + const [localSettings, setLocalSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, DEFAULT_APP_SETTINGS, AppSettingsSchema, ); + const unifiedSettings = useSettings(); + const compatUnifiedSettings = useMemo( + () => ({ + confirmThreadDelete: unifiedSettings.confirmThreadDelete, + defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, + diffWordWrap: unifiedSettings.diffWordWrap, + enableAssistantStreaming: unifiedSettings.enableAssistantStreaming, + providers: unifiedSettings.providers, + sidebarProjectSortOrder: unifiedSettings.sidebarProjectSortOrder, + sidebarThreadSortOrder: unifiedSettings.sidebarThreadSortOrder, + timestampFormat: unifiedSettings.timestampFormat, + }), + [unifiedSettings], + ); + const { updateSettings: updateUnifiedSettings, resetSettings: resetUnifiedSettings } = + useUpdateSettings(); + const settings = useMemo( + () => withUnifiedCompatSettings(localSettings, compatUnifiedSettings), + [compatUnifiedSettings, localSettings], + ); + const defaults = useMemo( + () => + withUnifiedCompatSettings(DEFAULT_APP_SETTINGS, { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }), + [], + ); // Apply legacy key migration that the schema decode path doesn't handle // Migrate legacy "claudeCode" keys to "claudeAgent" in record-typed settings @@ -265,19 +435,30 @@ export function useAppSettings() { const updateSettings = useCallback( (patch: Partial) => { - setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); + const unifiedPatch = toUnifiedPatch(patch); + if (Object.keys(unifiedPatch).length > 0) { + updateUnifiedSettings(unifiedPatch); + } + + const localPatch = stripMirroredKeys(patch); + if (Object.keys(localPatch).length === 0) { + return; + } + + setLocalSettings((prev) => normalizeAppSettings({ ...prev, ...localPatch })); }, - [setSettings], + [setLocalSettings, updateUnifiedSettings], ); const resetSettings = useCallback(() => { - setSettings(DEFAULT_APP_SETTINGS); - }, [setSettings]); + resetUnifiedSettings(); + setLocalSettings(DEFAULT_APP_SETTINGS); + }, [resetUnifiedSettings, setLocalSettings]); return { settings: migratedSettings, updateSettings, resetSettings, - defaults: DEFAULT_APP_SETTINGS, + defaults, } as const; } From 270ca4eab51baeaa0514a38eda2021a1efc820b2 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 14:43:01 +0530 Subject: [PATCH 37/45] Align Copilot provider probing with runtime fallback --- .../provider/Layers/ProviderRegistry.test.ts | 47 ++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 92 +++++++++---------- 2 files changed, 93 insertions(+), 46 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 4e3f33f591..27ffcd87f1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -487,6 +487,53 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + it.effect("probes Copilot from its default command when binary path is unset", () => + Effect.gen(function* () { + const serverSettingsLayer = ServerSettingsService.layerTest(); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + if (command === "claude") { + return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + } + if (command === "copilot") { + return { stdout: "copilot 2.3.4\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "spawn ENOENT", code: 1 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: "Authenticated\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected command: ${command} ${joined}`); + }), + ), + ); + + const providers = yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + return yield* registry.getProviders; + }).pipe(Effect.provide(providerRegistryLayer)); + + const copilot = providers.find((provider) => provider.provider === "copilot"); + assert.isDefined(copilot); + assert.strictEqual(copilot?.status, "ready"); + assert.strictEqual(copilot?.installed, true); + assert.notStrictEqual( + copilot?.message, + "Copilot is enabled, but no binary path is configured for probing.", + ); + }), + ); + it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { const serverSettingsLayer = ServerSettingsService.layerTest({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index e2031b8668..0582ceeae8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -24,6 +24,7 @@ import { fetchKiloModels } from "../../kiloServerManager"; import { fetchOpenCodeModels } from "../../opencodeServerManager"; import { ServerSettingsService } from "../../serverSettings"; import { fetchCopilotModels } from "./CopilotAdapter"; +import { resolveBundledCopilotCliPath } from "./copilotCliPath"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; import { fetchCursorModels } from "./CursorAdapter"; @@ -232,27 +233,33 @@ function buildReadySnapshot(input: { const runBinaryBackedSnapshot = ( provider: ProviderWithBinary, settings: ProviderSettingsShape, - fetchDiscoveredModels?: - | ((binaryPath: string | undefined) => Promise>) - | undefined, + options?: { + readonly fetchDiscoveredModels?: + | ((binaryPath: string | undefined) => Promise>) + | undefined; + readonly resolveProbeBinaryPath?: + | ((binaryPath: string | undefined) => string | undefined) + | undefined; + }, ) => Effect.gen(function* () { - const binaryPath = trimToUndefined(settings.binaryPath); - const discoveredModels = - binaryPath && fetchDiscoveredModels - ? yield* Effect.tryPromise({ - try: () => fetchDiscoveredModels(binaryPath), - catch: wrapProbeError, - }).pipe( - Effect.catchCause((cause) => { - const error = unwrapProbeError(Cause.squash(cause)); - if (isCommandMissingCause(error)) { - return Effect.succeed>([]); - } - return Effect.failCause(cause); - }), - ) - : []; + const configuredBinaryPath = trimToUndefined(settings.binaryPath); + const binaryPath = + options?.resolveProbeBinaryPath?.(configuredBinaryPath) ?? configuredBinaryPath; + const discoveredModels = options?.fetchDiscoveredModels + ? yield* Effect.tryPromise({ + try: () => options.fetchDiscoveredModels?.(binaryPath) ?? Promise.resolve([]), + catch: wrapProbeError, + }).pipe( + Effect.catchCause((cause) => { + const error = unwrapProbeError(Cause.squash(cause)); + if (isCommandMissingCause(error)) { + return Effect.succeed>([]); + } + return Effect.failCause(cause); + }), + ) + : []; const baseModels = discoveredModels.length > 0 @@ -264,7 +271,7 @@ const runBinaryBackedSnapshot = ( return buildDisabledSnapshot(provider, settings, models); } - if (!binaryPath && !fetchDiscoveredModels) { + if (!binaryPath && !options?.fetchDiscoveredModels) { return buildWarningSnapshot({ provider, settings, @@ -274,16 +281,6 @@ const runBinaryBackedSnapshot = ( }); } - if (!binaryPath && fetchDiscoveredModels) { - return buildWarningSnapshot({ - provider, - settings, - models, - installed: true, - message: `${PROVIDER_LABELS[provider]} is enabled, but no binary path is configured for probing.`, - }); - } - const versionProbe = binaryPath ? yield* Effect.tryPromise({ try: () => runVersionCommand(binaryPath), @@ -348,26 +345,29 @@ const loadProviderSnapshot = (deps: ProviderRegistryDeps, provider: ProviderKind case "claudeAgent": return yield* deps.claudeProvider.getSnapshot; case "copilot": - return yield* runBinaryBackedSnapshot("copilot", settings.providers.copilot, (binaryPath) => - fetchCopilotModels(binaryPath).then((models) => - (models ?? []).map((model) => ({ slug: model.slug, name: model.name })), - ), - ); + return yield* runBinaryBackedSnapshot("copilot", settings.providers.copilot, { + fetchDiscoveredModels: (binaryPath) => + fetchCopilotModels(binaryPath).then((models) => + (models ?? []).map((model) => ({ slug: model.slug, name: model.name })), + ), + resolveProbeBinaryPath: (binaryPath) => + binaryPath ?? resolveBundledCopilotCliPath() ?? "copilot", + }); case "cursor": - return yield* runBinaryBackedSnapshot("cursor", settings.providers.cursor, (binaryPath) => - fetchCursorModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), - ); + return yield* runBinaryBackedSnapshot("cursor", settings.providers.cursor, { + fetchDiscoveredModels: (binaryPath) => + fetchCursorModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + }); case "opencode": - return yield* runBinaryBackedSnapshot( - "opencode", - settings.providers.opencode, - (binaryPath) => + return yield* runBinaryBackedSnapshot("opencode", settings.providers.opencode, { + fetchDiscoveredModels: (binaryPath) => fetchOpenCodeModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), - ); + }); case "kilo": - return yield* runBinaryBackedSnapshot("kilo", settings.providers.kilo, (binaryPath) => - fetchKiloModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), - ); + return yield* runBinaryBackedSnapshot("kilo", settings.providers.kilo, { + fetchDiscoveredModels: (binaryPath) => + fetchKiloModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + }); case "geminiCli": void fetchGeminiCliUsage(); return yield* runBinaryBackedSnapshot("geminiCli", settings.providers.geminiCli); From 70baa7ca2ddc6a03de6eef264680e0ac4e76f7a7 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 15:46:07 +0530 Subject: [PATCH 38/45] Address review feedback and fix CI regressions --- .github/workflows/pr-size.yml | 8 +- apps/server/src/orchestration/projector.ts | 6 +- .../src/persistence/NodeSqliteClient.ts | 9 +- .../src/provider/Layers/CopilotAdapter.ts | 8 +- .../provider/Layers/ProviderRegistry.test.ts | 51 +++- .../src/provider/Layers/ProviderRegistry.ts | 79 ++++-- apps/server/src/serverSettings.test.ts | 28 +++ apps/web/src/appSettings.ts | 19 +- .../components/KeybindingsToast.browser.tsx | 12 +- apps/web/src/components/ProjectFavicon.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 236 +++++++++++++----- .../components/sidebar/SidebarUpdatePill.tsx | 15 +- apps/web/src/hooks/useSettings.test.ts | 25 +- apps/web/src/hooks/useSettings.ts | 21 ++ apps/web/src/hooks/useThreadActions.ts | 11 +- packages/contracts/src/settings.ts | 3 + 16 files changed, 414 insertions(+), 119 deletions(-) diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 15c04d663d..8865ad1c84 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -57,7 +57,7 @@ jobs: sync-label-definitions: name: Sync PR size label definitions needs: prepare-config - if: github.event_name != 'pull_request_target' + if: github.event_name == 'pull_request_target' runs-on: ubuntu-24.04 permissions: contents: read @@ -113,7 +113,7 @@ jobs: } label: name: Label PR size - needs: prepare-config + needs: [prepare-config, sync-label-definitions] if: github.event_name == 'pull_request_target' runs-on: ubuntu-24.04 permissions: @@ -209,8 +209,8 @@ jobs: }).trim(); if (resolvedHeadSha !== headSha) { - core.warning( - `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`, + throw new Error( + `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; retry once refs/pull/${issueNumber}/head catches up.`, ); } diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 1a8f28777d..947a9a0a17 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -45,12 +45,12 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } -function decodeForEvent( - schema: S, +function decodeForEvent
( + schema: Schema.Schema & { readonly DecodingServices: never }, value: unknown, eventType: OrchestrationEvent["type"], field: string, -): Effect.Effect { +): Effect.Effect { return Effect.try({ try: () => Schema.decodeUnknownSync(schema)(value), catch: (error) => toProjectorDecodeError(`${eventType}:${field}`)(error as Schema.SchemaError), diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 68f3df635b..1f9cb82a2b 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -50,6 +50,9 @@ export interface SqliteMemoryClientConfig extends Omit< "filename" | "readonly" > {} +const makeSqlError = (cause: unknown, message: string) => + new SqlError({ cause, message } as ConstructorParameters[0]); + /** * Verify that the current Node.js version includes the `node:sqlite` APIs * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added @@ -113,7 +116,7 @@ const makeWithDatabase = ( lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), - catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), + catch: (cause) => makeSqlError(cause, "Failed to prepare statement"), }), }); @@ -131,7 +134,7 @@ const makeWithDatabase = ( const result = statement.run(...(params as SQLInputValue[])); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { - return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); + return Effect.fail(makeSqlError(cause, "Failed to execute statement")); } }); @@ -154,7 +157,7 @@ const makeWithDatabase = ( statement.run(...(params as SQLInputValue[])); return []; }, - catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + catch: (cause) => makeSqlError(cause, "Failed to execute statement"), }), (statement) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index bd3044da81..2fb3479e34 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -46,11 +46,7 @@ import { recordTurnUsage, type CopilotTurnTrackingState, } from "./copilotTurnTracking.ts"; -import { - normalizeCopilotCliPathOverride, - resolveBundledCopilotCliPath, - withSanitizedCopilotDesktopEnv, -} from "./copilotCliPath.ts"; +import { resolveBundledCopilotCliPath, withSanitizedCopilotDesktopEnv } from "./copilotCliPath.ts"; import { CopilotAdapter, type CopilotAdapterShape } from "../Services/CopilotAdapter.ts"; import { toMessage } from "../toMessage.ts"; import type { @@ -1308,7 +1304,7 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const settingsBinaryPath = copilotSettings.binaryPath.trim(); const cliPath = settingsBinaryPath || resolveBundledCopilotCliPath(); resolvedCliPath = cliPath; - const configDir: string | undefined = undefined; + const configDir = trimToUndefined(copilotSettings.configDir); const resumeSessionId = extractResumeSessionId(input.resumeCursor); const clientOptions: CopilotClientOptions = { ...(cliPath ? { cliPath } : {}), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 27ffcd87f1..6dd66c53d3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -384,7 +384,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); - it.effect("reruns codex health when codex provider settings change", () => + it.effect("refreshes codex health when codex provider settings change", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService(); const scope = yield* Scope.make(); @@ -431,15 +431,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }, }); - for (let attempt = 0; attempt < 20; attempt += 1) { - const updated = yield* registry.getProviders; - if (updated.find((status) => status.provider === "codex")?.status === "error") { - return; - } - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); - } - - const updated = yield* registry.getProviders; + const updated = yield* registry.refresh("codex"); assert.strictEqual( updated.find((status) => status.provider === "codex")?.status, "error", @@ -534,6 +526,45 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + it.effect("serves cached provider snapshots from getProviders without re-probing", () => + Effect.gen(function* () { + let probeCount = 0; + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + probeCount += 1; + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + if (command === "claude") { + return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "spawn ENOENT", code: 1 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: "Authenticated\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected command: ${command} ${joined}`); + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + const initialProbeCount = probeCount; + yield* registry.getProviders; + yield* registry.getProviders; + assert.strictEqual(probeCount, initialProbeCount); + }).pipe(Effect.provide(providerRegistryLayer)); + }), + ); + it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { const serverSettingsLayer = ServerSettingsService.layerTest({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 0582ceeae8..c4e78c8163 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -86,6 +86,7 @@ type ProviderSettingsShape = { readonly enabled: boolean; readonly customModels: ReadonlyArray; readonly binaryPath?: string; + readonly configDir?: string; }; class ProviderSnapshotProbeError extends Error { override readonly cause: unknown; @@ -243,6 +244,16 @@ const runBinaryBackedSnapshot = ( }, ) => Effect.gen(function* () { + const fallbackModels = providerModelsFromSettings( + BUILT_IN_MODELS_BY_PROVIDER[provider], + provider, + settings.customModels, + ); + + if (!settings.enabled) { + return buildDisabledSnapshot(provider, settings, fallbackModels); + } + const configuredBinaryPath = trimToUndefined(settings.binaryPath); const binaryPath = options?.resolveProbeBinaryPath?.(configuredBinaryPath) ?? configuredBinaryPath; @@ -267,10 +278,6 @@ const runBinaryBackedSnapshot = ( : BUILT_IN_MODELS_BY_PROVIDER[provider]; const models = providerModelsFromSettings(baseModels, provider, settings.customModels); - if (!settings.enabled) { - return buildDisabledSnapshot(provider, settings, models); - } - if (!binaryPath && !options?.fetchDiscoveredModels) { return buildWarningSnapshot({ provider, @@ -287,11 +294,6 @@ const runBinaryBackedSnapshot = ( catch: wrapProbeError, }).pipe( Effect.map((result) => parseGenericCliVersion(`${result.stdout}\n${result.stderr}`)), - Effect.catchCause((cause) => - isCommandMissingCause(unwrapProbeError(Cause.squash(cause))) - ? Effect.succeed(null) - : Effect.failCause(cause), - ), ) : null; @@ -335,15 +337,23 @@ const runBinaryBackedSnapshot = ( }), ); -const loadProviderSnapshot = (deps: ProviderRegistryDeps, provider: ProviderKind) => +const loadProviderSnapshot = ( + deps: ProviderRegistryDeps, + provider: ProviderKind, + options?: { readonly forceRefreshManagedProviders?: boolean }, +) => Effect.gen(function* () { const settings = yield* deps.getSettings; switch (provider) { case "codex": - return yield* deps.codexProvider.getSnapshot; + return yield* options?.forceRefreshManagedProviders + ? deps.codexProvider.refresh + : deps.codexProvider.getSnapshot; case "claudeAgent": - return yield* deps.claudeProvider.getSnapshot; + return yield* options?.forceRefreshManagedProviders + ? deps.claudeProvider.refresh + : deps.claudeProvider.getSnapshot; case "copilot": return yield* runBinaryBackedSnapshot("copilot", settings.providers.copilot, { fetchDiscoveredModels: (binaryPath) => @@ -369,16 +379,24 @@ const loadProviderSnapshot = (deps: ProviderRegistryDeps, provider: ProviderKind fetchKiloModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), }); case "geminiCli": - void fetchGeminiCliUsage(); + if (settings.providers.geminiCli.enabled) { + void fetchGeminiCliUsage(); + } return yield* runBinaryBackedSnapshot("geminiCli", settings.providers.geminiCli); case "amp": - void fetchAmpUsage(); + if (settings.providers.amp.enabled) { + void fetchAmpUsage(); + } return yield* runBinaryBackedSnapshot("amp", settings.providers.amp); } }); -const loadProviders = (deps: ProviderRegistryDeps, providers: ReadonlyArray) => - Effect.forEach(providers, (provider) => loadProviderSnapshot(deps, provider), { +const loadProviders = ( + deps: ProviderRegistryDeps, + providers: ReadonlyArray, + options?: { readonly forceRefreshManagedProviders?: boolean }, +) => + Effect.forEach(providers, (provider) => loadProviderSnapshot(deps, provider, options), { concurrency: "unbounded", }); @@ -406,6 +424,23 @@ export const ProviderRegistryLive = Layer.effect( yield* loadProviders(deps, ALL_PROVIDERS), ); + const applyManagedProviderSnapshot = (snapshot: ServerProvider) => + Effect.gen(function* () { + const previousProviders = yield* Ref.get(providersRef); + const mergedProviders = ALL_PROVIDERS.map( + (provider) => + (provider === snapshot.provider + ? snapshot + : previousProviders.find((candidate) => candidate.provider === provider)) ?? + undefined, + ).filter((provider): provider is ServerProvider => provider !== undefined); + yield* Ref.set(providersRef, mergedProviders); + + if (haveProvidersChanged(previousProviders, mergedProviders)) { + yield* PubSub.publish(changesPubSub, mergedProviders); + } + }); + const syncProviders = ( providers: ReadonlyArray = ALL_PROVIDERS, options?: { readonly publish?: boolean }, @@ -415,7 +450,9 @@ export const ProviderRegistryLive = Layer.effect( const previousByProvider = new Map( previousProviders.map((provider) => [provider.provider, provider]), ); - const nextSnapshots = yield* loadProviders(deps, providers); + const nextSnapshots = yield* loadProviders(deps, providers, { + forceRefreshManagedProviders: true, + }); const mergedProviders = ALL_PROVIDERS.map( (provider) => nextSnapshots.find((snapshot) => snapshot.provider === provider) ?? @@ -436,12 +473,18 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(serverSettings.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(codexProvider.streamChanges, applyManagedProviderSnapshot).pipe( + Effect.forkScoped, + ); + yield* Stream.runForEach(claudeProvider.streamChanges, applyManagedProviderSnapshot).pipe( + Effect.forkScoped, + ); yield* Effect.forever( Effect.sleep("60 seconds").pipe(Effect.flatMap(() => syncProviders())), ).pipe(Effect.forkScoped); return { - getProviders: syncProviders(ALL_PROVIDERS, { publish: false }).pipe( + getProviders: Ref.get(providersRef).pipe( Effect.tapError(Effect.logError), Effect.orElseSucceed(() => []), ), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index f26fece246..d608137bdb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -24,6 +24,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(decodePatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), { providers: { codex: { binaryPath: "/tmp/codex" } }, }); + assert.deepEqual( + decodePatch({ + providers: { + copilot: { + binaryPath: "/tmp/copilot", + configDir: "/tmp/copilot-config", + }, + }, + }), + { + providers: { + copilot: { + binaryPath: "/tmp/copilot", + configDir: "/tmp/copilot-config", + }, + }, + }, + ); assert.deepEqual( decodePatch({ @@ -117,6 +135,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, + copilot: { + binaryPath: " /opt/homebrew/bin/copilot ", + configDir: " /Users/julius/.config/copilot ", + }, }, }); @@ -131,6 +153,12 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/claude", customModels: [], }); + assert.deepEqual(next.providers.copilot, { + enabled: true, + binaryPath: "/opt/homebrew/bin/copilot", + configDir: "/Users/julius/.config/copilot", + customModels: [], + }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 3448ac689a..bb09b79464 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -82,6 +82,8 @@ const MIRRORED_SERVER_KEYS = new Set([ "claudeBinaryPath", "codexBinaryPath", "codexHomePath", + "copilotCliPath", + "copilotConfigDir", "defaultThreadEnvMode", "enableAssistantStreaming", "customCodexModels", @@ -279,6 +281,8 @@ function withUnifiedCompatSettings( claudeBinaryPath: unifiedSettings.providers.claudeAgent.binaryPath, codexBinaryPath: unifiedSettings.providers.codex.binaryPath, codexHomePath: unifiedSettings.providers.codex.homePath, + copilotCliPath: unifiedSettings.providers.copilot.binaryPath, + copilotConfigDir: unifiedSettings.providers.copilot.configDir, defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, confirmThreadDelete: unifiedSettings.confirmThreadDelete, diffWordWrap: unifiedSettings.diffWordWrap, @@ -304,6 +308,7 @@ function toUnifiedPatch(patch: Partial): Partial { { binaryPath?: string; homePath?: string; + configDir?: string; customModels?: ReadonlyArray; } > @@ -319,6 +324,12 @@ function toUnifiedPatch(patch: Partial): Partial { binaryPath: patch.claudeBinaryPath, }; } + if (patch.copilotCliPath !== undefined || patch.copilotConfigDir !== undefined) { + providersPatch.copilot = { + ...(patch.copilotCliPath !== undefined ? { binaryPath: patch.copilotCliPath } : {}), + ...(patch.copilotConfigDir !== undefined ? { configDir: patch.copilotConfigDir } : {}), + }; + } const providerModelEntries = Object.entries(APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS) as Array< [ProviderKind, (typeof APP_SETTINGS_PROVIDER_CUSTOM_MODEL_KEYS)[ProviderKind]] >; @@ -445,14 +456,18 @@ export function useAppSettings() { return; } - setLocalSettings((prev) => normalizeAppSettings({ ...prev, ...localPatch })); + setLocalSettings((prev) => + normalizeAppSettings( + AppSettingsSchema.makeUnsafe(stripMirroredKeys({ ...prev, ...localPatch })), + ), + ); }, [setLocalSettings, updateUnifiedSettings], ); const resetSettings = useCallback(() => { resetUnifiedSettings(); - setLocalSettings(DEFAULT_APP_SETTINGS); + setLocalSettings(AppSettingsSchema.makeUnsafe(stripMirroredKeys(DEFAULT_APP_SETTINGS))); }, [resetUnifiedSettings, setLocalSettings]); return { diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index f150d241e1..3340e08177 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -63,12 +63,12 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, - copilot: { enabled: true, customModels: [], binaryPath: "" }, - cursor: { enabled: true, customModels: [], binaryPath: "" }, - opencode: { enabled: true, customModels: [], binaryPath: "" }, - geminiCli: { enabled: true, customModels: [], binaryPath: "" }, - amp: { enabled: true, customModels: [], binaryPath: "" }, - kilo: { enabled: true, customModels: [], binaryPath: "" }, + copilot: { enabled: true, customModels: [], binaryPath: "", configDir: "" }, + cursor: { enabled: true, customModels: [], binaryPath: "", configDir: "" }, + opencode: { enabled: true, customModels: [], binaryPath: "", configDir: "" }, + geminiCli: { enabled: true, customModels: [], binaryPath: "", configDir: "" }, + amp: { enabled: true, customModels: [], binaryPath: "", configDir: "" }, + kilo: { enabled: true, customModels: [], binaryPath: "", configDir: "" }, }, }, }; diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bc0118120f..d9356932da 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -9,7 +9,7 @@ function getServerHttpOrigin(): string { ? bridgeUrl : envUrl && envUrl.length > 0 ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`; + : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}`; // Parse to extract just the origin, dropping path/query (e.g. ?token=…) const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); try { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index dc086e8960..360ff7c56e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -92,25 +92,80 @@ type InstallProviderSettings = { homePathKey?: "codexHomePath"; homePlaceholder?: string; homeDescription?: ReactNode; + configDirKey?: "configDir"; + configDirPlaceholder?: string; + configDirDescription?: ReactNode; }; -const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", +const PROVIDER_ORDER = [ + "codex", + "copilot", + "claudeAgent", + "cursor", + "opencode", + "geminiCli", + "amp", + "kilo", +] as const satisfies ReadonlyArray; + +const PROVIDER_SETTINGS_OVERRIDES: Partial< + Record> +> = { + codex: { binaryPlaceholder: "Codex binary path", binaryDescription: "Path to the Codex binary", homePathKey: "codexHomePath", homePlaceholder: "CODEX_HOME", homeDescription: "Optional custom Codex home and config directory.", }, - { - provider: "claudeAgent", - title: "Claude", + copilot: { + binaryPlaceholder: "Copilot CLI path", + binaryDescription: "Path to the GitHub Copilot CLI binary", + configDirKey: "configDir", + configDirPlaceholder: "Copilot config directory", + configDirDescription: "Optional custom GitHub Copilot config directory.", + }, + claudeAgent: { binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", }, -] as const; +}; + +function getInstallProviderSettings(provider: ProviderKind): InstallProviderSettings { + const title = PROVIDER_DISPLAY_NAMES[provider] ?? provider; + const override = PROVIDER_SETTINGS_OVERRIDES[provider]; + return { + provider, + title, + binaryPlaceholder: override?.binaryPlaceholder ?? `${title} binary path`, + binaryDescription: override?.binaryDescription ?? `Path to the ${title} binary`, + ...(override?.homePathKey ? { homePathKey: override.homePathKey } : {}), + ...(override?.homePlaceholder ? { homePlaceholder: override.homePlaceholder } : {}), + ...(override?.homeDescription ? { homeDescription: override.homeDescription } : {}), + ...(override?.configDirKey ? { configDirKey: override.configDirKey } : {}), + ...(override?.configDirPlaceholder + ? { configDirPlaceholder: override.configDirPlaceholder } + : {}), + ...(override?.configDirDescription + ? { configDirDescription: override.configDirDescription } + : {}), + }; +} + +function getProviderSettingsForDisplay( + serverProviders: ReadonlyArray, +): ReadonlyArray { + const orderedProviders = + serverProviders.length > 0 + ? [ + ...serverProviders.map((provider) => provider.provider), + ...PROVIDER_ORDER.filter( + (provider) => !serverProviders.some((entry) => entry.provider === provider), + ), + ] + : [...PROVIDER_ORDER]; + return orderedProviders.map(getInstallProviderSettings); +} const PROVIDER_STATUS_STYLES = { disabled: { @@ -440,9 +495,9 @@ export function useSettingsRestore(onRestored?: () => void) { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); - const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { - const currentSettings = settings.providers[providerSettings.provider]; - const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + const areProviderSettingsDirty = PROVIDER_ORDER.some((provider) => { + const currentSettings = settings.providers[provider]; + const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[provider]; return !Equal.equals(currentSettings, defaultSettings); }); @@ -513,24 +568,17 @@ export function GeneralSettingsPanel() { const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openProviderDetails, setOpenProviderDetails] = useState< Partial> - >({ - codex: Boolean( - settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || - settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || - settings.providers.codex.customModels.length > 0, - ), - claudeAgent: Boolean( - settings.providers.claudeAgent.binaryPath !== - DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || - settings.providers.claudeAgent.customModels.length > 0, + >(() => + Object.fromEntries( + PROVIDER_ORDER.map((provider) => [ + provider, + !Equal.equals(settings.providers[provider], DEFAULT_UNIFIED_SETTINGS.providers[provider]), + ]), ), - }); + ); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Partial> - >({ - codex: "", - claudeAgent: "", - }); + >(() => Object.fromEntries(PROVIDER_ORDER.map((provider) => [provider, ""]))); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); @@ -557,6 +605,10 @@ export function GeneralSettingsPanel() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; + const providerSettings = useMemo( + () => getProviderSettingsForDisplay(serverProviders), + [serverProviders], + ); const codexHomePath = settings.providers.codex.homePath; const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); @@ -668,26 +720,29 @@ export function GeneralSettingsPanel() { const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { - updateSettings({ - providers: { - ...settings.providers, - [provider]: { - ...settings.providers[provider], - customModels: settings.providers[provider].customModels.filter( - (model) => model !== slug, - ), - }, + const nextProviders = { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: settings.providers[provider].customModels.filter((model) => model !== slug), }, + }; + updateSettings({ + providers: nextProviders, + textGenerationModelSelection: resolveAppModelSelectionState( + { ...settings, providers: nextProviders }, + serverProviders, + ), }); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, })); }, - [settings, updateSettings], + [serverProviders, settings, updateSettings], ); - const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { + const providerCards = providerSettings.map((providerSettings) => { const liveProvider = serverProviders.find( (candidate) => candidate.provider === providerSettings.provider, ); @@ -712,7 +767,11 @@ export function GeneralSettingsPanel() { homePathKey: providerSettings.homePathKey, homePlaceholder: providerSettings.homePlaceholder, homeDescription: providerSettings.homeDescription, + configDirKey: providerSettings.configDirKey, + configDirPlaceholder: providerSettings.configDirPlaceholder, + configDirDescription: providerSettings.configDirDescription, binaryPathValue: providerConfig.binaryPath, + configDirValue: "configDir" in providerConfig ? providerConfig.configDir : "", isDirty: !Equal.equals(providerConfig, defaultProviderConfig), liveProvider, models, @@ -1226,6 +1285,42 @@ export function GeneralSettingsPanel() {
) : null} + {providerCard.configDirKey ? ( +
+ +
+ ) : null} +
Models
@@ -1453,7 +1548,15 @@ export function ArchivedThreadsPanel() { } if (clicked === "delete") { - await confirmAndDeleteThread(threadId); + try { + await confirmAndDeleteThread(threadId); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } } }, [confirmAndDeleteThread, unarchiveThread], @@ -1500,24 +1603,45 @@ export function ArchivedThreadsPanel() { {formatRelativeTimeLabel(thread.createdAt)}

- +
+ + +
))} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 2f9aec112a..f6c482bb18 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -25,10 +25,11 @@ export function SidebarUpdatePill() { const queryClient = useQueryClient(); const state = useDesktopUpdateState().data ?? null; const [dismissed, setDismissed] = useState(false); + const [requestInFlight, setRequestInFlight] = useState(false); const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; - const disabled = isDesktopUpdateButtonDisabled(state); + const disabled = requestInFlight || isDesktopUpdateButtonDisabled(state); const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); @@ -38,9 +39,10 @@ export function SidebarUpdatePill() { const handleAction = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !state) return; - if (disabled || action === "none") return; + if (requestInFlight || disabled || action === "none") return; if (action === "download") { + setRequestInFlight(true); void bridge .downloadUpdate() .then((result) => { @@ -67,6 +69,9 @@ export function SidebarUpdatePill() { title: "Could not start update download", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); + }) + .finally(() => { + setRequestInFlight(false); }); return; } @@ -74,6 +79,7 @@ export function SidebarUpdatePill() { if (action === "install") { const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state)); if (!confirmed) return; + setRequestInFlight(true); void bridge .installUpdate() .then((result) => { @@ -93,9 +99,12 @@ export function SidebarUpdatePill() { title: "Could not install update", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); + }) + .finally(() => { + setRequestInFlight(false); }); } - }, [action, disabled, queryClient, state]); + }, [action, disabled, queryClient, requestInFlight, state]); if (!visible && !showArm64Warning) return null; diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 832ee17f7f..086abcbe31 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { buildLegacyClientSettingsMigrationPatch } from "./useSettings"; +import { + buildLegacyClientSettingsMigrationPatch, + buildLegacyServerSettingsMigrationPatch, +} from "./useSettings"; describe("buildLegacyClientSettingsMigrationPatch", () => { it("migrates archive confirmation from legacy local settings", () => { @@ -14,3 +17,23 @@ describe("buildLegacyClientSettingsMigrationPatch", () => { }); }); }); + +describe("buildLegacyServerSettingsMigrationPatch", () => { + it("migrates Copilot path, config, and custom model settings", () => { + expect( + buildLegacyServerSettingsMigrationPatch({ + copilotCliPath: "/usr/local/bin/copilot", + copilotConfigDir: "/Users/mav/.config/copilot", + customCopilotModels: ["copilot/custom-gpt"], + }), + ).toEqual({ + providers: { + copilot: { + binaryPath: "/usr/local/bin/copilot", + configDir: "/Users/mav/.config/copilot", + customModels: ["copilot/custom-gpt"], + }, + }, + }); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index bd79c16a80..52a67c1fbb 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -200,6 +200,27 @@ export function buildLegacyServerSettingsMigrationPatch(legacySettings: Record undefined); } - try { - await api.terminal.close({ threadId, deleteHistory: true }); - } catch { - // Terminal may already be closed. - } - const deletedThreadIds = opts.deletedThreadIds ?? new Set(); const shouldNavigateToFallback = routeThreadId === threadId; const fallbackThreadId = getFallbackThreadIdAfterDelete({ @@ -126,6 +120,11 @@ export function useThreadActions() { commandId: newCommandId(), threadId, }); + try { + await api.terminal.close({ threadId, deleteHistory: true }); + } catch { + // Terminal may already be closed. + } clearComposerDraftForThread(threadId); clearProjectDraftThreadById(thread.projectId, thread.id); clearTerminalState(threadId); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ae9774cb21..ba27dc21ea 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -81,6 +81,7 @@ export const GenericProviderSettings = Schema.Struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), binaryPath: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + configDir: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), }); export type GenericProviderSettings = typeof GenericProviderSettings.Type; @@ -219,6 +220,8 @@ const ClaudeSettingsPatch = Schema.Struct({ const GenericProviderSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + configDir: Schema.optionalKey(Schema.String), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); From abc6ec8f4ff21b478d973efc86eb3b337fa228d4 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 16:35:34 +0530 Subject: [PATCH 39/45] Address latest review feedback --- .../provider/Layers/ProviderRegistry.test.ts | 48 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 13 ++--- .../components/settings/SettingsPanels.tsx | 26 ++++++++-- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6dd66c53d3..3e65ebff08 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -526,6 +526,54 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ); + it.effect("reports cursor as unavailable when its CLI command is missing", () => + Effect.gen(function* () { + const serverSettingsLayer = ServerSettingsService.layerTest({ + providers: { + cursor: { + enabled: true, + binaryPath: "/tmp/t3-missing-cursor-cli", + }, + }, + }); + const providerRegistryLayer = ProviderRegistryLive.pipe( + Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge( + mockCommandSpawnerLayer((command, args) => { + const joined = args.join(" "); + if (joined === "--version") { + if (command === "codex") { + return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + } + if (command === "claude") { + return { stdout: "claude 1.0.0\n", stderr: "", code: 0 }; + } + return { stdout: "", stderr: "spawn ENOENT", code: 1 }; + } + if (joined === "login status") { + return { stdout: "Logged in\n", stderr: "", code: 0 }; + } + if (joined === "auth status") { + return { stdout: "Authenticated\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected command: ${command} ${joined}`); + }), + ), + ); + + const providers = yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + return yield* registry.getProviders; + }).pipe(Effect.provide(providerRegistryLayer)); + + const cursor = providers.find((provider) => provider.provider === "cursor"); + assert.isDefined(cursor); + assert.strictEqual(cursor?.status, "warning"); + assert.strictEqual(cursor?.installed, false); + assert.strictEqual(cursor?.message, "Cursor CLI not found on PATH."); + }), + ); + it.effect("serves cached provider snapshots from getProviders without re-probing", () => Effect.gen(function* () { let probeCount = 0; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index c4e78c8163..563be1b2b6 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -261,15 +261,7 @@ const runBinaryBackedSnapshot = ( ? yield* Effect.tryPromise({ try: () => options.fetchDiscoveredModels?.(binaryPath) ?? Promise.resolve([]), catch: wrapProbeError, - }).pipe( - Effect.catchCause((cause) => { - const error = unwrapProbeError(Cause.squash(cause)); - if (isCommandMissingCause(error)) { - return Effect.succeed>([]); - } - return Effect.failCause(cause); - }), - ) + }) : []; const baseModels = @@ -367,16 +359,19 @@ const loadProviderSnapshot = ( return yield* runBinaryBackedSnapshot("cursor", settings.providers.cursor, { fetchDiscoveredModels: (binaryPath) => fetchCursorModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + resolveProbeBinaryPath: (binaryPath) => binaryPath ?? "agent", }); case "opencode": return yield* runBinaryBackedSnapshot("opencode", settings.providers.opencode, { fetchDiscoveredModels: (binaryPath) => fetchOpenCodeModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + resolveProbeBinaryPath: (binaryPath) => binaryPath ?? "opencode", }); case "kilo": return yield* runBinaryBackedSnapshot("kilo", settings.providers.kilo, { fetchDiscoveredModels: (binaryPath) => fetchKiloModels(binaryPath ? { binaryPath } : {}).then((models) => [...models]), + resolveProbeBinaryPath: (binaryPath) => binaryPath ?? "kilo", }); case "geminiCli": if (settings.providers.geminiCli.enabled) { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 360ff7c56e..f026a40746 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -382,7 +382,7 @@ function AboutVersionSection() { const updateState = updateStateQuery.data ?? null; - const handleButtonClick = useCallback(() => { + const handleButtonClick = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge) return; @@ -405,7 +405,8 @@ function AboutVersionSection() { } if (action === "install") { - const confirmed = window.confirm( + const api = readNativeApi(); + const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( getDesktopUpdateInstallConfirmationMessage( updateState ?? { availableVersion: null, downloadedVersion: null }, ), @@ -586,6 +587,19 @@ export function GeneralSettingsPanel() { const refreshingRef = useRef(false); const queryClient = useQueryClient(); const modelListRefs = useRef>>({}); + const modelListObserverRef = useRef(null); + const modelListObserverTimeoutRef = useRef(null); + const clearModelListObserver = useCallback(() => { + modelListObserverRef.current?.disconnect(); + modelListObserverRef.current = null; + if (modelListObserverTimeoutRef.current !== null) { + window.clearTimeout(modelListObserverTimeoutRef.current); + modelListObserverTimeoutRef.current = null; + } + }, []); + + useEffect(() => clearModelListObserver, [clearModelListObserver]); + const refreshProviders = useCallback(() => { if (refreshingRef.current) return; refreshingRef.current = true; @@ -708,14 +722,16 @@ export function GeneralSettingsPanel() { if (!el) return; const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); requestAnimationFrame(scrollToEnd); + clearModelListObserver(); const observer = new MutationObserver(() => { scrollToEnd(); - observer.disconnect(); + clearModelListObserver(); }); + modelListObserverRef.current = observer; observer.observe(el, { childList: true, subtree: true }); - setTimeout(() => observer.disconnect(), 2_000); + modelListObserverTimeoutRef.current = window.setTimeout(clearModelListObserver, 2_000); }, - [customModelInputByProvider, serverProviders, settings, updateSettings], + [clearModelListObserver, customModelInputByProvider, serverProviders, settings, updateSettings], ); const removeCustomModel = useCallback( From 6ea4f4e0e8b4384eef1f03b6f5f3dbf95f2a400b Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 18:48:50 +0530 Subject: [PATCH 40/45] Make provider cache merges atomic --- .../src/provider/Layers/ProviderRegistry.ts | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 563be1b2b6..b83d806acd 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -418,18 +418,37 @@ export const ProviderRegistryLive = Layer.effect( const providersRef = yield* Ref.make>( yield* loadProviders(deps, ALL_PROVIDERS), ); + const mergeProvidersAtomically = ( + merge: ( + currentProviders: ReadonlyArray, + currentByProvider: ReadonlyMap, + ) => ReadonlyArray, + ) => + Ref.modify(providersRef, (currentProviders) => { + const currentByProvider = new Map( + currentProviders.map((provider) => [provider.provider, provider] as const), + ); + const mergedProviders = merge(currentProviders, currentByProvider); + + return [ + { + previousProviders: currentProviders, + mergedProviders, + }, + mergedProviders, + ] as const; + }); const applyManagedProviderSnapshot = (snapshot: ServerProvider) => Effect.gen(function* () { - const previousProviders = yield* Ref.get(providersRef); - const mergedProviders = ALL_PROVIDERS.map( - (provider) => - (provider === snapshot.provider - ? snapshot - : previousProviders.find((candidate) => candidate.provider === provider)) ?? - undefined, - ).filter((provider): provider is ServerProvider => provider !== undefined); - yield* Ref.set(providersRef, mergedProviders); + const { previousProviders, mergedProviders } = yield* mergeProvidersAtomically( + (_, currentByProvider) => + ALL_PROVIDERS.map( + (provider) => + (provider === snapshot.provider ? snapshot : currentByProvider.get(provider)) ?? + undefined, + ).filter((provider): provider is ServerProvider => provider !== undefined), + ); if (haveProvidersChanged(previousProviders, mergedProviders)) { yield* PubSub.publish(changesPubSub, mergedProviders); @@ -441,19 +460,19 @@ export const ProviderRegistryLive = Layer.effect( options?: { readonly publish?: boolean }, ) => Effect.gen(function* () { - const previousProviders = yield* Ref.get(providersRef); - const previousByProvider = new Map( - previousProviders.map((provider) => [provider.provider, provider]), - ); const nextSnapshots = yield* loadProviders(deps, providers, { forceRefreshManagedProviders: true, }); - const mergedProviders = ALL_PROVIDERS.map( - (provider) => - nextSnapshots.find((snapshot) => snapshot.provider === provider) ?? - previousByProvider.get(provider), - ).filter((provider): provider is ServerProvider => provider !== undefined); - yield* Ref.set(providersRef, mergedProviders); + const nextSnapshotsByProvider = new Map( + nextSnapshots.map((provider) => [provider.provider, provider] as const), + ); + const { previousProviders, mergedProviders } = yield* mergeProvidersAtomically( + (_, currentByProvider) => + ALL_PROVIDERS.map( + (provider) => + nextSnapshotsByProvider.get(provider) ?? currentByProvider.get(provider), + ).filter((provider): provider is ServerProvider => provider !== undefined), + ); if ( options?.publish !== false && From b5916a73d9d202498fb8c2529ae531d092d5ae30 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 19:09:00 +0530 Subject: [PATCH 41/45] Stabilize provider snapshot change detection --- apps/server/src/orchestration/projector.ts | 2 +- .../src/persistence/NodeSqliteClient.ts | 2 +- .../provider/Layers/ProviderRegistry.test.ts | 25 +++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 32 +++++++++++++++++-- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 947a9a0a17..7353b7605d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -52,7 +52,7 @@ function decodeForEvent
( field: string, ): Effect.Effect { return Effect.try({ - try: () => Schema.decodeUnknownSync(schema)(value), + try: () => Schema.decodeUnknownSync(schema as never)(value) as A, catch: (error) => toProjectorDecodeError(`${eventType}:${field}`)(error as Schema.SchemaError), }); } diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 1f9cb82a2b..de42285e10 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -51,7 +51,7 @@ export interface SqliteMemoryClientConfig extends Omit< > {} const makeSqlError = (cause: unknown, message: string) => - new SqlError({ cause, message } as ConstructorParameters[0]); + new SqlError({ cause, message } as unknown as ConstructorParameters[0]); /** * Verify that the current Node.js version includes the `node:sqlite` APIs diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3e65ebff08..419bccd999 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -384,6 +384,31 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); }); + it("ignores checkedAt-only changes when comparing provider snapshots", () => { + const previousProviders = [ + { + provider: "codex", + status: "ready", + enabled: true, + installed: true, + authStatus: "authenticated", + checkedAt: "2026-03-25T00:00:00.000Z", + version: "1.0.0", + message: "Ready", + models: [{ slug: "gpt-5", name: "GPT-5", isCustom: false, capabilities: null }], + }, + ] as const satisfies ReadonlyArray; + const nextProviders = [ + { + ...previousProviders[0], + checkedAt: "2026-03-25T00:01:00.000Z", + models: [...previousProviders[0].models], + }, + ] as const satisfies ReadonlyArray; + + assert.strictEqual(haveProvidersChanged(previousProviders, nextProviders), false); + }); + it.effect("refreshes codex health when codex provider settings change", () => Effect.gen(function* () { const serverSettings = yield* makeMutableServerSettingsService(); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index b83d806acd..ba40b02466 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -16,7 +16,7 @@ import { type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; -import { Cause, Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; +import { Cause, Effect, Layer, PubSub, Ref, Stream } from "effect"; import { fetchAmpUsage } from "../../ampServerManager"; import { fetchGeminiCliUsage } from "../../geminiCliServerManager"; @@ -398,7 +398,35 @@ const loadProviders = ( export const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, -): boolean => !Equal.equals(previousProviders, nextProviders); +): boolean => { + if (previousProviders.length !== nextProviders.length) { + return true; + } + + return previousProviders.some((previousProvider, index) => { + const nextProvider = nextProviders[index]; + if (!nextProvider) { + return true; + } + + return ( + JSON.stringify(toComparableProviderSnapshot(previousProvider)) !== + JSON.stringify(toComparableProviderSnapshot(nextProvider)) + ); + }); +}; + +const toComparableProviderSnapshot = (provider: ServerProvider) => ({ + provider: provider.provider, + enabled: provider.enabled, + installed: provider.installed, + version: provider.version, + status: provider.status, + authStatus: provider.authStatus, + message: provider.message ?? null, + models: provider.models, + quotaSnapshots: provider.quotaSnapshots ?? null, +}); export const ProviderRegistryLive = Layer.effect( ProviderRegistry, From c080a2a3dbbe75b0a1494d43902cac16828f4ac6 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 19:29:58 +0530 Subject: [PATCH 42/45] Fix provider snapshot cache test baseline --- apps/server/src/provider/Layers/ProviderRegistry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 419bccd999..31609ae6bc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -630,8 +630,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; - const initialProbeCount = probeCount; yield* registry.getProviders; + const initialProbeCount = probeCount; yield* registry.getProviders; assert.strictEqual(probeCount, initialProbeCount); }).pipe(Effect.provide(providerRegistryLayer)); From ed67a232f1d610b370cd19057cf2c568426de971 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 20:50:53 +0530 Subject: [PATCH 43/45] Fix SqlError constructor for effect@4.0.0-beta.42 The published beta.42 changed SqlError to require a `reason: SqlErrorReason` field instead of `{ cause, message }`. Use `classifySqliteError` to produce the correct reason type from native SQLite errors. --- apps/server/src/persistence/NodeSqliteClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index de42285e10..4d5d615a54 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { SqlError } from "effect/unstable/sql/SqlError"; +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -51,7 +51,7 @@ export interface SqliteMemoryClientConfig extends Omit< > {} const makeSqlError = (cause: unknown, message: string) => - new SqlError({ cause, message } as unknown as ConstructorParameters[0]); + new SqlError({ reason: classifySqliteError(cause, { message }) } as unknown as ConstructorParameters[0]); /** * Verify that the current Node.js version includes the `node:sqlite` APIs From 4cc9592cc6d4a2f1acb3352d85050a722b1c445b Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 20:54:14 +0530 Subject: [PATCH 44/45] Format NodeSqliteClient.ts --- apps/server/src/persistence/NodeSqliteClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 4d5d615a54..a55b6299de 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -51,7 +51,9 @@ export interface SqliteMemoryClientConfig extends Omit< > {} const makeSqlError = (cause: unknown, message: string) => - new SqlError({ reason: classifySqliteError(cause, { message }) } as unknown as ConstructorParameters[0]); + new SqlError({ + reason: classifySqliteError(cause, { message }), + } as unknown as ConstructorParameters[0]); /** * Verify that the current Node.js version includes the `node:sqlite` APIs From b134e5adc66ba8add323d551f937e01c47df1b0a Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 29 Mar 2026 21:10:59 +0530 Subject: [PATCH 45/45] Fix ultrathink warning text in browser test Update assertion to match the current UI copy. --- .../components/chat/CompactComposerControlsMenu.browser.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eee6f885e9..aeee5b2e57 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -253,9 +253,7 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; - expect(text).toContain( - 'Your prompt contains "ultrathink" in the text. Remove it to change effort.', - ); + expect(text).toContain("Remove Ultrathink from the prompt to change effort."); }); }); });