diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..705bd8f43e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# git autocrlf=true converts LF to CRLF on Windows, causing issues with oxfmt +* text=auto eol=lf diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 7c42830805..5535d54a5b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -13,7 +13,6 @@ github:adityavardhansharma github:binbandit github:chuks-qua github:cursoragent -github:eggfriedrice24 github:gbarros-dev github:github-actions[bot] github:hwanseoc @@ -28,4 +27,6 @@ github:PatrickBauer github:realAhmedRoach github:shiroyasha9 github:Yash-Singh1 +github:eggfriedrice24 github:Ymit24 +github:shivamhwp diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 472d14a625..8865ad1c84 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,50 +192,56 @@ 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, ); - const isFileListTruncated = files.length >= 3000; - if (isFileListTruncated) { - core.warning( - "The GitHub pull request files API may truncate results at 3,000 files; forcing size:XXL.", + const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { + encoding: "utf8", + }).trim(); + + if (resolvedHeadSha !== headSha) { + throw new Error( + `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; retry once refs/pull/${issueNumber}/head catches up.`, ); } - let testChangedLines = 0; - let nonTestChangedLines = 0; - - if (!isFileListTruncated) { - 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 = isFileListTruncated - ? 1000 - : (nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines); - const nextLabelName = isFileListTruncated - ? "size:XXL" - : resolveSizeLabel(changedLines); + const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; + const nextLabelName = resolveSizeLabel(changedLines); const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, 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/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/desktop/src/preload.ts b/apps/desktop/src/preload.ts index f348ea1a79..e651c30b39 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,9 +9,9 @@ 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 UPDATE_CHECK_CHANNEL = "desktop:update-check"; const LOG_DIR_CHANNEL = "desktop:log-dir"; const LOG_LIST_CHANNEL = "desktop:log-list"; const LOG_READ_CHANNEL = "desktop:log-read"; 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 a473563930..4c8c9aafca 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -7,7 +7,8 @@ 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?.trim() || "/bin/zsh"; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 115d18d02b..547401ac5f 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -124,7 +124,7 @@ function waitFor( read: Effect.Effect, predicate: (value: A) => boolean, description: string, - timeoutMs = 10_000, + timeoutMs = 60_000, ): Effect.Effect { const RETRY_SIGNAL = "wait_for_retry"; const retryIntervalMs = 10; @@ -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), @@ -364,7 +365,7 @@ export const makeOrchestrationIntegrationHarness = ( const receiptHistory = yield* Ref.make>([]); yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), - ).pipe(Effect.forkIn(scope)); + ).pipe(Effect.forkIn(scope, { startImmediately: true })); yield* Effect.sleep(10); const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index c8e2eb8ee3..7ac1f91170 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -57,7 +57,7 @@ function waitForSync( read: () => A, predicate: (value: A) => boolean, description: string, - timeoutMs = 3000, + timeoutMs = 10_000, ): Effect.Effect { return Effect.gen(function* () { const deadline = Date.now() + timeoutMs; @@ -745,6 +745,18 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git messageId: "msg-user-revert-1", text: "First edit", }); + yield* harness.waitForReceipt( + (receipt): receipt is CheckpointDiffFinalizedReceipt => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); + yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 1, + ); yield* harness.waitForThread( THREAD_ID, @@ -803,6 +815,18 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git messageId: "msg-user-revert-2", text: "Second edit", }); + yield* harness.waitForReceipt( + (receipt): receipt is CheckpointDiffFinalizedReceipt => + receipt.type === "checkpoint.diff.finalized" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 2, + ); + yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === THREAD_ID && + receipt.checkpointTurnCount === 2, + ); yield* harness.waitForThread( THREAD_ID, @@ -810,7 +834,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && entry.activities.some((activity) => activity.turnId === "turn-2"), - 8000, ); yield* harness.engine.dispatch({ diff --git a/apps/server/package.json b/apps/server/package.json index dc2e870d9f..0ce8e03d90 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/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, diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 157efd4b0f..3afb29270e 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -61,6 +61,7 @@ function makeSnapshot(input: { }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, deletedAt: null, messages: [], activities: [], 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..a26a5ef8e5 --- /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 = 6_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 05999"); + }), + ); + }); +}); 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("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/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 635b9e8bc4..3be495bbf0 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -3,7 +3,7 @@ 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 { Effect, Fiber, FileSystem, Layer, PlatformError, Scope } from "effect"; import { describe, expect, vi } from "vitest"; import { GitCoreLive, makeGitCore } from "./GitCore.ts"; @@ -145,7 +145,6 @@ it.layer(TestLayer)("git integration", (it) => { maxOutputBytes: 128, }); - expect(result.code).toBe(0); expect(result.stdout.length).toBeLessThanOrEqual(128); expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); }), @@ -448,12 +447,15 @@ it.layer(TestLayer)("git integration", (it) => { yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); const core = yield* GitCore; yield* Effect.promise(() => - vi.waitFor(async () => { - const details = await Effect.runPromise(core.statusDetails(source)); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }), + vi.waitFor( + async () => { + const details = await Effect.runPromise(core.statusDetails(source)); + expect(details.branch).toBe(featureBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }, + { timeout: 20_000 }, + ), ); }), ); @@ -498,9 +500,12 @@ it.layer(TestLayer)("git integration", (it) => { }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => - vi.waitFor(() => { - expect(refreshFetchAttempts).toBe(1); - }), + vi.waitFor( + () => { + expect(refreshFetchAttempts).toBe(1); + }, + { timeout: 20_000 }, + ), ); expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); }), @@ -538,9 +543,12 @@ it.layer(TestLayer)("git integration", (it) => { }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => - vi.waitFor(() => { - expect(fetchArgs).not.toBeNull(); - }), + vi.waitFor( + () => { + expect(fetchArgs).not.toBeNull(); + }, + { timeout: 5_000 }, + ), ); expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); @@ -576,28 +584,40 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); const realGitCore = yield* GitCore; - let fetchStarted = false; let releaseFetch!: () => void; const waitForReleasePromise = new Promise((resolve) => { releaseFetch = resolve; }); const core = yield* makeIsolatedGitCore((input) => { if (input.args[0] === "fetch") { - fetchStarted = true; return Effect.promise(() => waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), ); } return realGitCore.execute(input); }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); + let checkoutCompleted = false; + const checkoutFiber = yield* core + .checkoutBranch({ cwd: source, branch: featureBranch }) + .pipe( + Effect.tap(() => + Effect.sync(() => { + checkoutCompleted = true; + }), + ), + Effect.forkChild({ startImmediately: true }), + ); yield* Effect.promise(() => - vi.waitFor(() => { - expect(fetchStarted).toBe(true); - }), + vi.waitFor( + () => { + expect(checkoutCompleted).toBe(true); + }, + { timeout: 5_000 }, + ), ); expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); releaseFetch(); + yield* Fiber.join(checkoutFiber); }), ); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index a5cb24a264..6aee03b4f4 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", @@ -1211,12 +1246,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, @@ -1412,14 +1450,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" }, ); @@ -1817,8 +1874,9 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" }); // Refresh upstream refs in the background so checkout remains responsive. - yield* Effect.forkScoped( - refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), + yield* refreshCheckedOutBranchUpstream(input.cwd).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkScoped({ startImmediately: true }), ); }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 0f6677bc31..21d171bfff 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -22,6 +22,8 @@ import { type CommitMessageGenerationResult, type PrContentGenerationInput, type PrContentGenerationResult, + type ThreadTitleGenerationInput, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -65,6 +67,9 @@ interface FakeGitTextGeneration { generateBranchName: ( input: BranchNameGenerationInput, ) => Effect.Effect; + generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; } type FakePullRequest = NonNullable; @@ -168,6 +173,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ branch: "update-workflow", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), ...overrides, }; @@ -205,6 +214,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 } : {}), + }), + ), + ), }; } @@ -495,6 +515,10 @@ function createSessionTextGeneration( Effect.succeed({ branch: "session-generated-branch", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Session generated thread", + }), ...overrides, }; @@ -502,6 +526,7 @@ function createSessionTextGeneration( generateCommitMessage: implementation.generateCommitMessage, generatePrContent: implementation.generatePrContent, generateBranchName: implementation.generateBranchName, + generateThreadTitle: implementation.generateThreadTitle, }; } @@ -648,7 +673,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ); }), - 12_000, + 30_000, ); it.effect("status returns merged PR state when latest PR was merged", () => @@ -1222,7 +1247,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), - 12_000, + 30_000, ); it.effect( @@ -1284,7 +1309,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), - 12_000, + 30_000, ); it.effect( @@ -1338,7 +1363,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head octocat:statemachine --state open --limit 1", ); }), - 12_000, + 30_000, ); it.effect("creates PR when one does not already exist", () => diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 48ac9fcbd3..c7996b471c 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -53,6 +53,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/Layers/SessionTextGeneration.ts b/apps/server/src/git/Layers/SessionTextGeneration.ts index 29ba36f7e5..01a32ba0d5 100644 --- a/apps/server/src/git/Layers/SessionTextGeneration.ts +++ b/apps/server/src/git/Layers/SessionTextGeneration.ts @@ -12,6 +12,8 @@ import { type BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, + type ThreadTitleGenerationInput, + type ThreadTitleGenerationResult, } from "../Services/TextGeneration.ts"; import { SessionTextGeneration, @@ -30,6 +32,11 @@ function sanitizePrTitle(raw: string): string { return singleLine.length > 0 ? singleLine : "Update project changes"; } +function sanitizeThreadTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + return singleLine.length > 0 ? singleLine : "New thread"; +} + function extractJsonObject(raw: string): string { const trimmed = raw.trim(); if (trimmed.startsWith("```")) { @@ -49,7 +56,11 @@ function toThreadId(value: string): ThreadId { } function normalizeProviderTextGenerationError( - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", error: unknown, fallback: string, ): TextGenerationError { @@ -73,7 +84,11 @@ function normalizeProviderTextGenerationError( } function decodeJsonResponse( - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", raw: string, schema: S, ): Effect.Effect { @@ -129,7 +144,11 @@ const makeSessionTextGeneration = Effect.gen(function* () { attachments, schema, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; provider: BranchNameGenerationInput["provider"]; model: BranchNameGenerationInput["model"]; @@ -414,10 +433,56 @@ const makeSessionTextGeneration = Effect.gen(function* () { ); }; + const generateThreadTitle: SessionTextGenerationShape["generateThreadTitle"] = (input) => { + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + const promptSections = [ + "You generate concise thread titles.", + "Answer using only valid JSON. Do not use tools, do not ask for approvals, and do not add markdown fences or prose.", + 'Return a JSON object with key: "title".', + "Rules:", + "- Keep the title short and specific.", + "- Use the user's request as the main signal.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + + return runProviderJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + provider: input.modelSelection.provider, + model: input.modelSelection.model, + prompt: promptSections.join("\n"), + attachments: input.attachments, + schema: Schema.Struct({ + title: Schema.String, + }), + }).pipe( + Effect.map( + (generated) => + ({ + title: sanitizeThreadTitle(generated.title), + }) satisfies ThreadTitleGenerationResult, + ), + ); + }; + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies SessionTextGenerationShape; }); 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/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; } diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 6139696f33..e8ec364569 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -67,12 +67,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; } /** @@ -99,6 +112,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/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 66b2b6d6b9..88296ffb4d 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"; @@ -77,6 +78,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/server/src/main.test.ts b/apps/server/src/main.test.ts index af47aa5e24..99f327f04a 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)); }); @@ -118,7 +120,7 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.logWebSocketEvents, true); assert.equal(stop.mock.calls.length, 1); }), - 15_000, + 60_000, ); it.effect("supports --token as an alias for --auth-token", () => @@ -332,6 +334,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( @@ -371,7 +388,7 @@ it.layer(testLayer)("server CLI command", (it) => { it.effect("does not start server for invalid --mode values", () => Effect.gen(function* () { - yield* runCli(["--mode", "invalid"]); + yield* runCli(["--mode", "invalid"]).pipe(Effect.catch(() => Effect.void)); assert.equal(start.mock.calls.length, 0); assert.equal(stop.mock.calls.length, 0); @@ -389,7 +406,7 @@ it.layer(testLayer)("server CLI command", (it) => { it.effect("does not start server for out-of-range --port values", () => Effect.gen(function* () { - yield* runCli(["--port", "70000"]); + yield* runCli(["--port", "70000"]).pipe(Effect.catch(() => Effect.void)); // effect/unstable/cli renders help/errors for parse failures and returns success. assert.equal(start.mock.calls.length, 0); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 6e7842b65e..d3020eebd7 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -115,7 +115,7 @@ async function waitForThread( checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; }) => boolean, - timeoutMs = 5000, + timeoutMs = 20_000, ) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise<{ @@ -140,7 +140,7 @@ async function waitForThread( async function waitForEvent( engine: OrchestrationEngineShape, predicate: (event: { type: string }) => boolean, - timeoutMs = 5000, + timeoutMs = 20_000, ) { const deadline = Date.now() + timeoutMs; const poll = async () => { @@ -191,7 +191,7 @@ function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { return runGit(cwd, ["show", `${ref}:${filePath}`]); } -async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 5000) { +async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 20_000) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { if (gitRefExists(cwd, ref)) { @@ -1005,7 +1005,7 @@ describe("CheckpointReactor", () => { }), ); - const deadline = Date.now() + 2000; + const deadline = Date.now() + 20_000; const waitForRollbackCalls = async (): Promise => { if (harness.provider.rollbackConversation.mock.calls.length >= 2) { return; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index e4a673342c..dcf6d06fa8 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -767,28 +767,24 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processInputSafely); const start: CheckpointReactorShape["start"] = Effect.gen(function* () { - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if ( - event.type !== "thread.turn-start-requested" && - event.type !== "thread.message-sent" && - event.type !== "thread.checkpoint-revert-requested" && - event.type !== "thread.turn-diff-completed" - ) { - return Effect.void; - } - return worker.enqueue({ source: "domain", event }); - }), - ); + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if ( + event.type !== "thread.turn-start-requested" && + event.type !== "thread.message-sent" && + event.type !== "thread.checkpoint-revert-requested" && + event.type !== "thread.turn-diff-completed" + ) { + return Effect.void; + } + return worker.enqueue({ source: "domain", event }); + }).pipe(Effect.forkScoped({ startImmediately: true })); - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => { - if (event.type !== "turn.started" && event.type !== "turn.completed") { - return Effect.void; - } - return worker.enqueue({ source: "runtime", event }); - }), - ); + yield* Stream.runForEach(providerService.streamEvents, (event) => { + if (event.type !== "turn.started" && event.type !== "turn.completed") { + return Effect.void; + } + return worker.enqueue({ source: "runtime", event }); + }).pipe(Effect.forkScoped({ startImmediately: true })); }); return { diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 69b28b9d3c..182f350fc3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -204,7 +204,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { ); const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); - yield* Effect.forkScoped(worker); + yield* worker.pipe(Effect.forkScoped({ startImmediately: true })); yield* Effect.log("orchestration engine started").pipe( Effect.annotateLogs({ sequence: readModel.snapshotSequence }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ce68d654ef..7d85d1e38c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -428,10 +428,41 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, + archivedAt: null, deletedAt: null, }); return; + case "thread.archived": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + archivedAt: event.payload.archivedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unarchived": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + archivedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5080ea8c48..32143d751f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -279,6 +279,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }, createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", + archivedAt: null, deletedAt: null, messages: [ { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cc2f4f87e7..f951c54b5b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -174,6 +174,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", + archived_at AS "archivedAt", deleted_at AS "deletedAt" FROM projection_threads ORDER BY created_at ASC, thread_id ASC @@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, + archivedAt: row.archivedAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fffbb1e942..f9d2e0498b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -774,8 +774,9 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processDomainEventSafely); - const start: ProviderCommandReactorShape["start"] = Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + const start: ProviderCommandReactorShape["start"] = Stream.runForEach( + orchestrationEngine.streamDomainEvents, + (event) => { if ( event.type !== "thread.deleted" && event.type !== "thread.runtime-mode-set" && @@ -789,8 +790,8 @@ const make = Effect.gen(function* () { } return worker.enqueue(event); - }), - ).pipe(Effect.asVoid); + }, + ).pipe(Effect.forkScoped({ startImmediately: true }), Effect.asVoid); return { start, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b554c2bd6e..dc96104088 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"; @@ -57,7 +56,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; @@ -67,6 +66,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[] = []; @@ -94,8 +110,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 { @@ -1967,6 +1998,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 60f7a777bb..3c7ff33863 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1655,19 +1655,15 @@ const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker(processInputSafely); const start: ProviderRuntimeIngestionShape["start"] = Effect.gen(function* () { - yield* Effect.forkScoped( - Stream.runForEach(providerService.streamEvents, (event) => - worker.enqueue({ source: "runtime", event }), - ), - ); - yield* Effect.forkScoped( - Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { - if (event.type !== "thread.turn-start-requested") { - return Effect.void; - } - return worker.enqueue({ source: "domain", event }); - }), - ); + yield* Stream.runForEach(providerService.streamEvents, (event) => + worker.enqueue({ source: "runtime", event }), + ).pipe(Effect.forkScoped({ startImmediately: true })); + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + if (event.type !== "thread.turn-start-requested") { + return Effect.void; + } + return worker.enqueue({ source: "domain", event }); + }).pipe(Effect.forkScoped({ startImmediately: true })); }); return { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index c96385cad1..f7ebf69344 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -3,10 +3,12 @@ import { ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, + ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, + ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -26,10 +28,12 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; +export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; +export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index b07eb4234f..43d665a2c9 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -66,6 +66,7 @@ const readModel: OrchestrationReadModel = { worktreePath: null, createdAt: now, updatedAt: now, + archivedAt: null, latestTurn: null, messages: [], session: null, @@ -88,6 +89,7 @@ const readModel: OrchestrationReadModel = { worktreePath: null, createdAt: now, updatedAt: now, + archivedAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 6acb9c82ba..009fdb190e 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -88,6 +88,44 @@ export function requireThread(input: { ); } +export function requireThreadArchived(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { + return requireThread(input).pipe( + Effect.flatMap((thread) => + thread.archivedAt !== null + ? Effect.succeed(thread) + : Effect.fail( + invariantError( + input.command.type, + `Thread '${input.threadId}' is not archived for command '${input.command.type}'.`, + ), + ), + ), + ); +} + +export function requireThreadNotArchived(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { + return requireThread(input).pipe( + Effect.flatMap((thread) => + thread.archivedAt === null + ? Effect.succeed(thread) + : Effect.fail( + invariantError( + input.command.type, + `Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`, + ), + ), + ), + ); +} + export function requireThreadAbsent(input: { readonly readModel: OrchestrationReadModel; readonly command: OrchestrationCommand; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 47df8f3dfc..f032ab00d7 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -190,6 +190,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.archive": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = command.createdAt; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.archived", + payload: { + threadId: command.threadId, + archivedAt: occurredAt, + updatedAt: occurredAt, + }, + }; + } + + case "thread.unarchive": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = command.createdAt; + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.unarchived", + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }; + } + case "thread.meta.update": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fd95d028d8..3dcdd19250 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -87,6 +87,7 @@ describe("orchestration projector", () => { latestTurn: null, createdAt: now, updatedAt: now, + archivedAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -131,6 +132,78 @@ describe("orchestration projector", () => { ).rejects.toBeDefined(); }); + it("applies thread.archived and thread.unarchived events", async () => { + const now = new Date().toISOString(); + const later = new Date(Date.parse(now) + 1_000).toISOString(); + const created = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + + const archived = await Effect.runPromise( + projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.archived", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-archive", + payload: { + threadId: "thread-1", + archivedAt: later, + updatedAt: later, + }, + }), + ), + ); + expect(archived.threads[0]?.archivedAt).toBe(later); + + const unarchived = await Effect.runPromise( + projectEvent( + archived, + makeEvent({ + sequence: 3, + type: "thread.unarchived", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: later, + commandId: "cmd-thread-unarchive", + payload: { + threadId: "thread-1", + updatedAt: later, + }, + }), + ), + ); + expect(unarchived.threads[0]?.archivedAt).toBeNull(); + }); + it("keeps projector forward-compatible for unhandled event types", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index e8336362b4..7353b7605d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -14,12 +14,14 @@ import { ProjectDeletedPayload, ProjectMetaUpdatedPayload, ThreadActivityAppendedPayload, + ThreadArchivedPayload, ThreadCreatedPayload, ThreadDeletedPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadUnarchivedPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -43,14 +45,14 @@ 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), + try: () => Schema.decodeUnknownSync(schema as never)(value) as A, catch: (error) => toProjectorDecodeError(`${eventType}:${field}`)(error as Schema.SchemaError), }); } @@ -260,6 +262,7 @@ export function projectEvent( latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, + archivedAt: null, deletedAt: null, messages: [], activities: [], @@ -289,6 +292,28 @@ export function projectEvent( })), ); + case "thread.archived": + return decodeForEvent(ThreadArchivedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + archivedAt: payload.archivedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unarchived": + return decodeForEvent(ThreadUnarchivedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + archivedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ 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/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 0ca13f2e97..b0e1774837 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", + archivedAt: null, deletedAt: null, }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 344f199092..48dd51fdca 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -39,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { latest_turn_id, created_at, updated_at, + archived_at, deleted_at ) VALUES ( @@ -53,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, + ${row.archivedAt}, ${row.deletedAt} ) ON CONFLICT (thread_id) @@ -67,6 +69,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, + archived_at = excluded.archived_at, deleted_at = excluded.deleted_at `, }); @@ -88,6 +91,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", + archived_at AS "archivedAt", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} @@ -111,6 +115,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", + archived_at AS "archivedAt", deleted_at AS "deletedAt" FROM projection_threads WHERE project_id = ${projectId} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 12cc0b29c1..65886d7907 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -30,6 +30,8 @@ import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplemen import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_NormalizeLegacyClaudeCodeProvider.ts"; +import Migration0018 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; +import Migration0019 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; /** * Migration loader with all migrations defined inline. @@ -59,6 +61,8 @@ export const migrationEntries = [ [15, "ProjectionTurnsSourceProposedPlan", Migration0015], [16, "CanonicalizeModelSelections", Migration0016], [17, "NormalizeLegacyClaudeCodeProvider", Migration0017], + [18, "ProjectionThreadsArchivedAt", Migration0018], + [19, "ProjectionThreadsArchivedAtIndex", Migration0019], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 039a63d60b..d74e9fe08c 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -128,6 +128,20 @@ layer("016_CanonicalizeModelSelections", (it) => { '{"projectId":"project-2","title":"Fallback Project","workspaceRoot":"/tmp/project-2","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"low"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', '{}' ), + ( + 'event-project-created-null-model', + 'project', + 'project-3', + 1, + 'project.created', + '2026-01-01T00:00:00.000Z', + 'command-project-created-null-model', + NULL, + 'correlation-project-created-null-model', + 'user', + '{"projectId":"project-3","title":"Null Model Project","workspaceRoot":"/tmp/project-3","defaultModel":null,"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}', + '{}' + ), ( 'event-thread-created', 'thread', @@ -284,6 +298,16 @@ layer("016_CanonicalizeModelSelections", (it) => { }); assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), { + projectId: "project-3", + title: "Null Model Project", + workspaceRoot: "/tmp/project-3", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), { threadId: "thread-1", projectId: "project-1", title: "Thread", @@ -303,7 +327,7 @@ layer("016_CanonicalizeModelSelections", (it) => { updatedAt: "2026-01-01T00:00:00.000Z", }); - assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), { + assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), { threadId: "thread-2", projectId: "project-1", title: "Fallback Thread", @@ -322,7 +346,7 @@ layer("016_CanonicalizeModelSelections", (it) => { updatedAt: "2026-01-01T00:00:00.000Z", }); - assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), { + assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), { threadId: "thread-1", turnId: "turn-1", input: "hi", @@ -336,7 +360,7 @@ layer("016_CanonicalizeModelSelections", (it) => { deliveryMode: "buffered", }); - assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), { + assert.deepStrictEqual(JSON.parse(eventRows[6]!.payloadJson), { threadId: "thread-3", projectId: "project-1", title: "Ancient Thread", diff --git a/apps/server/src/persistence/Migrations/017_ProjectionThreadsArchivedAt.ts b/apps/server/src/persistence/Migrations/017_ProjectionThreadsArchivedAt.ts new file mode 100644 index 0000000000..35d05f189b --- /dev/null +++ b/apps/server/src/persistence/Migrations/017_ProjectionThreadsArchivedAt.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (columns.some((column) => column.name === "archived_at")) { + return; + } + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN archived_at TEXT + `; +}); diff --git a/apps/server/src/persistence/Migrations/018_ProjectionThreadsArchivedAtIndex.ts b/apps/server/src/persistence/Migrations/018_ProjectionThreadsArchivedAtIndex.ts new file mode 100644 index 0000000000..b12ab1bcfb --- /dev/null +++ b/apps/server/src/persistence/Migrations/018_ProjectionThreadsArchivedAtIndex.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_project_archived_at + ON projection_threads(project_id, archived_at) + `; +}); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 68f3df635b..a55b6299de 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"; @@ -50,6 +50,11 @@ export interface SqliteMemoryClientConfig extends Omit< "filename" | "readonly" > {} +const makeSqlError = (cause: unknown, message: string) => + new SqlError({ + reason: classifySqliteError(cause, { message }), + } as unknown as ConstructorParameters[0]); + /** * Verify that the current Node.js version includes the `node:sqlite` APIs * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added @@ -113,7 +118,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 +136,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 +159,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/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index cf4bd55a81..59505c1253 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -32,6 +32,7 @@ export const ProjectionThread = Schema.Struct({ latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, + archivedAt: Schema.NullOr(IsoDateTime), deletedAt: Schema.NullOr(IsoDateTime), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index cbf4b76063..5402612887 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,7 +37,7 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } -function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { +export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { if (process.platform !== "win32") return false; if (code === 9009) return true; return /is not recognized as an internal or external command/i.test(stderr); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 30fbc77c32..28d94a86b9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1104,13 +1104,11 @@ describe("ClaudeAdapterLive", () => { const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; - const runtimeEventsFiber = Effect.runFork( - Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => { - runtimeEvents.push(event); - }), - ), - ); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }), + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, @@ -1200,9 +1198,10 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = Effect.runFork( - Stream.runForEach(adapter.streamEvents, () => Effect.void), - ); + const runtimeEventsFiber = yield* Stream.runForEach( + adapter.streamEvents, + () => Effect.void, + ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId: THREAD_ID, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 350d0538d5..91198b3b5c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -519,6 +519,40 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + 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/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 bed25977d6..31609ae6bc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -384,7 +384,32 @@ 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("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(); const scope = yield* Scope.make(); @@ -431,15 +456,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", @@ -448,6 +465,179 @@ 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("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("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; + 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; + yield* registry.getProviders; + const initialProbeCount = probeCount; + 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 1e66ce8ff5..ba40b02466 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -1,87 +1,537 @@ /** - * 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, 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 { resolveBundledCopilotCliPath } from "./copilotCliPath"; 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 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; + readonly configDir?: 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, + options?: { + readonly fetchDiscoveredModels?: + | ((binaryPath: string | undefined) => Promise>) + | undefined; + readonly resolveProbeBinaryPath?: + | ((binaryPath: string | undefined) => string | undefined) + | undefined; + }, +) => + 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; + const discoveredModels = options?.fetchDiscoveredModels + ? yield* Effect.tryPromise({ + try: () => options.fetchDiscoveredModels?.(binaryPath) ?? Promise.resolve([]), + catch: wrapProbeError, + }) + : []; + + const baseModels = + discoveredModels.length > 0 + ? mergeBuiltInAndDiscoveredModels(provider, discoveredModels) + : BUILT_IN_MODELS_BY_PROVIDER[provider]; + const models = providerModelsFromSettings(baseModels, provider, settings.customModels); + + if (!binaryPath && !options?.fetchDiscoveredModels) { + return buildWarningSnapshot({ + provider, + settings, + models, + installed: true, + message: `${PROVIDER_LABELS[provider]} runtime is enabled, but installation status is not probed yet.`, + }); + } + + const versionProbe = binaryPath + ? yield* Effect.tryPromise({ + try: () => runVersionCommand(binaryPath), + catch: wrapProbeError, + }).pipe( + Effect.map((result) => parseGenericCliVersion(`${result.stdout}\n${result.stderr}`)), + ) + : 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, + options?: { readonly forceRefreshManagedProviders?: boolean }, +) => + Effect.gen(function* () { + const settings = yield* deps.getSettings; + + switch (provider) { + case "codex": + return yield* options?.forceRefreshManagedProviders + ? deps.codexProvider.refresh + : deps.codexProvider.getSnapshot; + case "claudeAgent": + return yield* options?.forceRefreshManagedProviders + ? deps.claudeProvider.refresh + : deps.claudeProvider.getSnapshot; + case "copilot": + 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, { + 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) { + void fetchGeminiCliUsage(); + } + return yield* runBinaryBackedSnapshot("geminiCli", settings.providers.geminiCli); + case "amp": + if (settings.providers.amp.enabled) { + void fetchAmpUsage(); + } + return yield* runBinaryBackedSnapshot("amp", settings.providers.amp); + } + }); + const loadProviders = ( - codexProvider: CodexProviderShape, - claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + deps: ProviderRegistryDeps, + providers: ReadonlyArray, + options?: { readonly forceRefreshManagedProviders?: boolean }, +) => + Effect.forEach(providers, (provider) => loadProviderSnapshot(deps, provider, options), { concurrency: "unbounded", }); 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, 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 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, mergedProviders } = yield* mergeProvidersAtomically( + (_, currentByProvider) => + ALL_PROVIDERS.map( + (provider) => + (provider === snapshot.provider ? snapshot : currentByProvider.get(provider)) ?? + undefined, + ).filter((provider): provider is ServerProvider => provider !== undefined), + ); - const syncProviders = (options?: { readonly publish?: boolean }) => + if (haveProvidersChanged(previousProviders, mergedProviders)) { + yield* PubSub.publish(changesPubSub, mergedProviders); + } + }); + + 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 nextSnapshots = yield* loadProviders(deps, providers, { + forceRefreshManagedProviders: true, + }); + 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 && 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* 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({ publish: false }).pipe( + getProviders: Ref.get(providersRef).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/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f192c036c2..91e584e268 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -193,12 +193,12 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const worker = Effect.forever( Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), ); - yield* Effect.forkScoped(worker); + yield* worker.pipe(Effect.forkScoped({ startImmediately: true })); yield* Effect.forEach(adapters, (adapter) => Stream.runForEach(adapter.streamEvents, (event) => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid), - ).pipe(Effect.forkScoped), + ).pipe(Effect.forkScoped({ startImmediately: true })), ).pipe(Effect.asVoid); const recoverSessionForThread = (input: { 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/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 53ee7d74f1..5717fda39e 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -672,9 +672,9 @@ describe("TerminalManager", () => { ).toBe(true); } else { expect( - ptyAdapter.spawnInputs.some((input) => - ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), - ), + ptyAdapter.spawnInputs + .slice(1) + .some((input) => input.shell !== "/definitely/missing-shell"), ).toBe(true); } diff --git a/apps/server/src/wsServer/pushBus.ts b/apps/server/src/wsServer/pushBus.ts index 355db330a9..73821eb900 100644 --- a/apps/server/src/wsServer/pushBus.ts +++ b/apps/server/src/wsServer/pushBus.ts @@ -74,19 +74,17 @@ export const makeServerPushBus = (input: { ); }); - yield* Effect.forkScoped( - Effect.forever( - Queue.take(queue).pipe( - Effect.flatMap((job) => - send(job).pipe( - Effect.tap((delivered) => settleDelivery(job, delivered)), - Effect.tapCause(() => settleDelivery(job, false)), - Effect.ignoreCause({ log: true }), - ), + yield* Effect.forever( + Queue.take(queue).pipe( + Effect.flatMap((job) => + send(job).pipe( + Effect.tap((delivered) => settleDelivery(job, delivered)), + Effect.tapCause(() => settleDelivery(job, false)), + Effect.ignoreCause({ log: true }), ), ), ), - ); + ).pipe(Effect.forkScoped({ startImmediately: true })); const publish = (target: PushTarget) => diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index e69a935939..85a16b64ce 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -6,8 +6,12 @@ export default mergeConfig( baseConfig, defineConfig({ test: { - testTimeout: 15_000, - hookTimeout: 15_000, + // The server suite spins up long-lived Effect runtimes and git subprocesses. + // Running files serially avoids worker-pool teardown stalls and the full-suite + // timing races that only appear under heavy parallel contention. + fileParallelism: false, + testTimeout: 60_000, + hookTimeout: 60_000, server: { deps: { // @github/copilot-sdk imports "vscode-jsonrpc/node" which fails diff --git a/apps/web/package.json b/apps/web/package.json index 858c4acd9c..2ed3fc67a8 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/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5e6a63ff11..bb09b79464 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,40 @@ 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", + "copilotCliPath", + "copilotConfigDir", + "defaultThreadEnvMode", + "enableAssistantStreaming", + "customCodexModels", + "customCopilotModels", + "customClaudeModels", + "customCursorModels", + "customOpencodeModels", + "customGeminiCliModels", + "customAmpModels", + "customKiloModels", +]); const withDefaults = < @@ -222,6 +262,119 @@ 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, + copilotCliPath: unifiedSettings.providers.copilot.binaryPath, + copilotConfigDir: unifiedSettings.providers.copilot.configDir, + 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; + configDir?: 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, + }; + } + 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]] + >; + 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 +391,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 +446,34 @@ 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( + AppSettingsSchema.makeUnsafe(stripMirroredKeys({ ...prev, ...localPatch })), + ), + ); }, - [setSettings], + [setLocalSettings, updateUnifiedSettings], ); const resetSettings = useCallback(() => { - setSettings(DEFAULT_APP_SETTINGS); - }, [setSettings]); + resetUnifiedSettings(); + setLocalSettings(AppSettingsSchema.makeUnsafe(stripMirroredKeys(DEFAULT_APP_SETTINGS))); + }, [resetUnifiedSettings, setLocalSettings]); return { settings: migratedSettings, updateSettings, resetSettings, - defaults: DEFAULT_APP_SETTINGS, + defaults, } as const; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx new file mode 100644 index 0000000000..a2b27bb1e9 --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -0,0 +1,49 @@ +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import ThreadSidebar from "./Sidebar"; +import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; + +const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; +const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; + +export function AppSidebarLayout({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + + useEffect(() => { + const 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, + }} + > + + + + {children} + + ); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5af65012ec..6ebaeb55d5 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 ( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e6db8b2f55..e2b71e77f3 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -39,6 +39,7 @@ export function buildLocalDraftThread( messages: [], error, createdAt: draftThread.createdAt, + archivedAt: null, latestTurn: null, lastVisitedAt: draftThread.createdAt, branch: draftThread.branch, 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 new file mode 100644 index 0000000000..d9356932da --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -0,0 +1,49 @@ +import { FolderIcon } from "lucide-react"; +import { useState } from "react"; + +function getServerHttpOrigin(): string { + const bridgeUrl = window.desktopBridge?.getWsUrl(); + const envUrl = import.meta.env.VITE_WS_URL as string | undefined; + const wsUrl = + bridgeUrl && bridgeUrl.length > 0 + ? bridgeUrl + : envUrl && envUrl.length > 0 + ? envUrl + : `${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 { + return new URL(httpUrl).origin; + } catch { + return httpUrl; + } +} + +const serverHttpOrigin = getServerHttpOrigin(); + +const loadedProjectFaviconSrcs = new Set(); + +export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { + const src = `${serverHttpOrigin}/api/project-favicon?cwd=${encodeURIComponent(cwd)}`; + const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => + loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + ); + + return ( + <> + {status !== "loaded" ? ( + + ) : null} + { + loadedProjectFaviconSrcs.add(src); + setStatus("loaded"); + }} + onError={() => setStatus("error")} + /> + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index dcfe3f6b69..b54ec1cb93 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + getVisibleSidebarThreadIds, + resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, + isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, @@ -96,6 +99,112 @@ 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( + 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, @@ -380,6 +489,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], error: null, createdAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, @@ -697,6 +807,43 @@ describe("sortProjectsForSidebar", () => { ]); }); + it("ignores archived threads when sorting projects", () => { + const sorted = sortProjectsForSidebar( + [ + makeProject({ + id: ProjectId.makeUnsafe("project-1"), + name: "Visible project", + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeProject({ + id: ProjectId.makeUnsafe("project-2"), + name: "Archived-only project", + updatedAt: "2026-03-09T10:00:00.000Z", + }), + ], + [ + makeThread({ + id: ThreadId.makeUnsafe("thread-visible"), + projectId: ProjectId.makeUnsafe("project-1"), + updatedAt: "2026-03-09T10:02:00.000Z", + archivedAt: null, + }), + makeThread({ + id: ThreadId.makeUnsafe("thread-archived"), + projectId: ProjectId.makeUnsafe("project-2"), + updatedAt: "2026-03-09T10:10:00.000Z", + archivedAt: "2026-03-09T10:11:00.000Z", + }), + ].filter((thread) => thread.archivedAt === null), + "updated_at", + ); + + expect(sorted.map((project) => project.id)).toEqual([ + ProjectId.makeUnsafe("project-1"), + ProjectId.makeUnsafe("project-2"), + ]); + }); + it("returns the project timestamp when no threads are present", () => { const timestamp = getProjectSortTimestamp( makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6ca29d27e9..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,54 @@ 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; + 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/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index aa3550e007..aeee5b2e57 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,25 @@ describe("CompactComposerControlsMenu", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("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("Remove Ultrathink from the prompt to change effort."); - expect(text).not.toContain("Fallback Effort"); }); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 528818a5cc..f4c0e5c547 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,5 @@ import { MessageId } from "@t3tools/contracts"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; @@ -11,6 +12,12 @@ function matchMedia() { } beforeAll(() => { + const localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + }; const classList = { add: () => {}, remove: () => {}, @@ -18,17 +25,13 @@ beforeAll(() => { contains: () => false, }; - vi.stubGlobal("localStorage", { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - }); + vi.stubGlobal("localStorage", localStorage); vi.stubGlobal("window", { matchMedia, addEventListener: () => {}, removeEventListener: () => {}, desktopBridge: undefined, + localStorage, }); vi.stubGlobal("document", { documentElement: { @@ -45,51 +48,54 @@ beforeAll(() => { describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const queryClient = new QueryClient(); const markup = renderToStaticMarkup( - ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), + + ", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, }, - }, - ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, + ]} + completionDividerBeforeEntryId={null} + completionSummary={null} + turnDiffSummaryByAssistantMessageId={new Map()} + nowIso="2026-03-17T19:12:30.000Z" + expandedWorkGroups={{}} + onToggleWorkGroup={() => {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + /> + , ); expect(markup).toContain("Terminal 1 lines 1-5"); @@ -99,43 +105,46 @@ describe("MessagesTimeline", () => { it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); + const queryClient = new QueryClient(); const markup = renderToStaticMarkup( - + {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, + ]} + completionDividerBeforeEntryId={null} + completionSummary={null} + turnDiffSummaryByAssistantMessageId={new Map()} + nowIso="2026-03-17T19:12:30.000Z" + expandedWorkGroups={{}} + onToggleWorkGroup={() => {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + /> + , ); expect(markup).toContain("Context compacted"); diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b1..84bde53048 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + canCheckForUpdate, getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, - shouldHighlightDesktopUpdateError, shouldShowArm64IntelBuildWarning, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, @@ -69,6 +70,16 @@ describe("desktop update button state", () => { 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, @@ -145,38 +156,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: true, - state: baseState, + completed: false, + state: { ...baseState, message: null }, }), ).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, + shouldToastDesktopUpdateActionResult({ + accepted: true, + completed: true, + state: { ...baseState, message: "checksum mismatch" }, }), ).toBe(false); }); @@ -206,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 faf30883cc..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 { @@ -87,10 +91,20 @@ 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 { 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 new file mode 100644 index 0000000000..f026a40746 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -0,0 +1,1668 @@ +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 ModelSelection, + 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 { + 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, + 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; + configDirKey?: "configDir"; + configDirPlaceholder?: string; + configDirDescription?: ReactNode; +}; + +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.", + }, + 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", + }, +}; + +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: { + 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: ReactNode; + 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}
+
+ ); +} + +function AboutVersionTitle() { + return ( + + Version + {APP_VERSION} + + ); +} + +function AboutVersionSection() { + const queryClient = useQueryClient(); + const updateStateQuery = useDesktopUpdateState(); + + const updateState = updateStateQuery.data ?? null; + + const handleButtonClick = useCallback(async () => { + 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 api = readNativeApi(); + const confirmed = await (api ?? ensureNativeApi()).dialogs.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((state) => { + setDesktopUpdateStateQueryData(queryClient, state); + }) + .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(); + const { resetSettings } = useUpdateSettings(); + + const isGitWritingModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + const areProviderSettingsDirty = PROVIDER_ORDER.some((provider) => { + const currentSettings = settings.providers[provider]; + const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[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< + Partial> + >(() => + Object.fromEntries( + PROVIDER_ORDER.map((provider) => [ + provider, + !Equal.equals(settings.providers[provider], DEFAULT_UNIFIED_SETTINGS.providers[provider]), + ]), + ), + ); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Partial> + >(() => Object.fromEntries(PROVIDER_ORDER.map((provider) => [provider, ""]))); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + 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; + 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 providerSettings = useMemo( + () => getProviderSettingsForDisplay(serverProviders), + [serverProviders], + ); + 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); + clearModelListObserver(); + const observer = new MutationObserver(() => { + scrollToEnd(); + clearModelListObserver(); + }); + modelListObserverRef.current = observer; + observer.observe(el, { childList: true, subtree: true }); + modelListObserverTimeoutRef.current = window.setTimeout(clearModelListObserver, 2_000); + }, + [clearModelListObserver, customModelInputByProvider, serverProviders, settings, updateSettings], + ); + + const removeCustomModel = useCallback( + (provider: ProviderKind, slug: string) => { + 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, + })); + }, + [serverProviders, settings, updateSettings], + ); + + const providerCards = providerSettings.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, + 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, + 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 } : {}), + } as ModelSelection, + }, + 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} + + {providerCard.configDirKey ? ( +
+ +
+ ) : 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={ + + } + /> + + + + {isElectron ? ( + + ) : ( + } + description="Current version of the application." + /> + )} + +
+ ); +} + +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") { + 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], + ); + + 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..6ba698c91f --- /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: "Archive", 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/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx new file mode 100644 index 0000000000..f6c482bb18 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -0,0 +1,185 @@ +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 [requestInFlight, setRequestInFlight] = useState(false); + + const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; + const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; + const disabled = requestInFlight || 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 (requestInFlight || disabled || action === "none") return; + + if (action === "download") { + setRequestInFlight(true); + 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.", + }); + }) + .finally(() => { + setRequestInFlight(false); + }); + return; + } + + if (action === "install") { + const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state)); + if (!confirmed) return; + setRequestInFlight(true); + 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.", + }); + }) + .finally(() => { + setRequestInFlight(false); + }); + } + }, [action, disabled, queryClient, requestInFlight, 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/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/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 49f6fe8538..6fa6de482f 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -4,7 +4,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; function getStorage(): Storage { if (typeof window !== "undefined") { try { - return window.localStorage; + const storage = window.localStorage; + if (storage) { + return storage; + } } catch { // localStorage blocked (e.g. sandboxed iframe, privacy mode) } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts new file mode 100644 index 0000000000..086abcbe31 --- /dev/null +++ b/apps/web/src/hooks/useSettings.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + buildLegacyClientSettingsMigrationPatch, + buildLegacyServerSettingsMigrationPatch, +} from "./useSettings"; + +describe("buildLegacyClientSettingsMigrationPatch", () => { + it("migrates archive confirmation from legacy local settings", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + confirmThreadArchive: true, + confirmThreadDelete: false, + }), + ).toEqual({ + confirmThreadArchive: true, + confirmThreadDelete: false, + }); + }); +}); + +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 dbc5b42d8b..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> { 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..a108f4cc20 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.ts @@ -0,0 +1,212 @@ +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, + createdAt: new Date().toISOString(), + }); + + 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, + createdAt: new Date().toISOString(), + }); + }, []); + + 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); + } + + 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, + }); + try { + await api.terminal.close({ threadId, deleteHistory: true }); + } catch { + // Terminal may already be closed. + } + 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/keybindings.ts b/apps/web/src/keybindings.ts index 48cdb8c612..1e50e062c1 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/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/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/modelSelection.ts b/apps/web/src/modelSelection.ts new file mode 100644 index 0000000000..e349fba092 --- /dev/null +++ b/apps/web/src/modelSelection.ts @@ -0,0 +1,105 @@ +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, + type ModelSelection, + type ProviderKind, + type ProviderModelOptions, + type ServerProvider, +} from "@t3tools/contracts"; +import type { UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveSelectableModel } from "@t3tools/shared/model"; + +import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; +import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH } from "./customModels"; +import { + getDefaultServerModel, + getProviderModels, + resolveSelectableProvider, +} from "./providerModels"; + +export { MAX_CUSTOM_MODEL_LENGTH }; + +export function getCustomModelOptionsByProvider( + settings: UnifiedSettings, + _providers?: ReadonlyArray, + selectedProvider?: ProviderKind | null, + selectedModel?: string | null, +) { + return { + codex: getAppModelOptions( + "codex", + settings.providers.codex.customModels, + selectedProvider === "codex" ? selectedModel : undefined, + ), + copilot: getAppModelOptions( + "copilot", + settings.providers.copilot.customModels, + selectedProvider === "copilot" ? selectedModel : undefined, + ), + claudeAgent: getAppModelOptions( + "claudeAgent", + settings.providers.claudeAgent.customModels, + selectedProvider === "claudeAgent" ? selectedModel : undefined, + ), + cursor: getAppModelOptions( + "cursor", + settings.providers.cursor.customModels, + selectedProvider === "cursor" ? selectedModel : undefined, + ), + opencode: getAppModelOptions( + "opencode", + settings.providers.opencode.customModels, + selectedProvider === "opencode" ? selectedModel : undefined, + ), + geminiCli: getAppModelOptions( + "geminiCli", + settings.providers.geminiCli.customModels, + selectedProvider === "geminiCli" ? selectedModel : undefined, + ), + amp: getAppModelOptions( + "amp", + settings.providers.amp.customModels, + selectedProvider === "amp" ? selectedModel : undefined, + ), + kilo: getAppModelOptions( + "kilo", + settings.providers.kilo.customModels, + selectedProvider === "kilo" ? selectedModel : undefined, + ), + } as const; +} + +export function resolveAppModelSelectionState( + settings: UnifiedSettings, + providers: ReadonlyArray, +): ModelSelection { + const selection = settings.textGenerationModelSelection ?? { + provider: "codex" as const, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + }; + const provider = resolveSelectableProvider(providers, selection.provider); + const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); + + const model = + resolveSelectableModel( + provider, + provider === selection.provider ? selection.model : null, + modelOptionsByProvider[provider], + ) ?? getDefaultServerModel(providers, provider); + + const providerModelOptions: Partial = { + [provider]: provider === selection.provider ? selection.options : undefined, + }; + const { modelOptionsForDispatch } = getComposerProviderState({ + provider, + model, + models: getProviderModels(providers, provider), + prompt: "", + modelOptions: providerModelOptions, + }); + + return { + provider, + model, + ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}), + } as ModelSelection; +} 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/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx deleted file mode 100644 index 51e0a39289..0000000000 --- a/apps/web/src/routes/_chat.settings.tsx +++ /dev/null @@ -1,1669 +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 { - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - type DesktopUpdateState, - PROVIDER_DISPLAY_NAMES, - type ProviderKind, - type ServerProvider, - type ServerProviderModel, -} from "@t3tools/contracts"; -import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; - -import { - APP_PROVIDER_LOGO_APPEARANCE_OPTIONS, - getAppModelOptions, - getCustomModelsForProvider, - MAX_CUSTOM_MODEL_LENGTH, - MODEL_PROVIDER_SETTINGS, - patchCustomModels, - patchGitTextGenerationModelOverrides, - useAppSettings, -} from "../appSettings"; -import { ACCENT_COLOR_PRESETS, DEFAULT_ACCENT_COLOR, normalizeAccentColor } from "../accentColor"; -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 { 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"; - -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 GIT_TEXT_GENERATION_INHERIT_VALUE = "__inherit__"; - -// --------------------------------------------------------------------------- -// Log syntax highlighting -// --------------------------------------------------------------------------- - -const LOG_TOKEN_PATTERN = - /(?timestamp|level|fiber|message|cause|span\.\w+)=(?"(?:[^"\\]|\\.)*"|[^\s]+)/g; - -const LOG_LEVEL_COLORS: Record = { - Info: "text-blue-400", - Warning: "text-amber-400", - Error: "text-red-400", - Debug: "text-zinc-500", - Fatal: "text-red-500 font-semibold", -}; - -function highlightLogLine(line: string): ReactNode { - const parts: ReactNode[] = []; - let lastIndex = 0; - - for (const match of line.matchAll(LOG_TOKEN_PATTERN)) { - const start = match.index; - if (start > lastIndex) { - parts.push(line.slice(lastIndex, start)); - } - - const key = match.groups?.key ?? ""; - const value = match.groups?.value ?? ""; - - if (key === "timestamp") { - parts.push( - - {key}={value} - , - ); - } else if (key === "level") { - const levelClass = LOG_LEVEL_COLORS[value] ?? "text-muted-foreground"; - parts.push( - - {key}={value} - , - ); - } else if (key === "fiber") { - parts.push( - - {key}={value} - , - ); - } else if (key === "message" || key === "cause") { - parts.push( - - {key}= - {value} - , - ); - } else { - parts.push( - - {key}={value} - , - ); - } - - lastIndex = start + match[0].length; - } - - if (lastIndex < line.length) { - parts.push(line.slice(lastIndex)); - } - - return parts.length > 0 ? parts : line; -} - -function HighlightedLogContent({ content }: { content: string }) { - const lines = content.split("\n"); - return ( - <> - {lines.map((line, lineIndex) => ( - // eslint-disable-next-line react/no-array-index-key -- static log lines never reorder - - {highlightLogLine(line)} - {lineIndex < lines.length - 1 ? "\n" : null} - - ))} - - ); -} - -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; - -type InstallProviderSettings = { - provider: ProviderKind; - title: string; - binaryPathKey: "claudeBinaryPath" | "codexBinaryPath"; - binaryPlaceholder: string; - binaryDescription: ReactNode; - homePathKey?: "codexHomePath"; - homePlaceholder?: string; - homeDescription?: ReactNode; -}; - -const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ - { - provider: "codex", - title: "Codex", - binaryPathKey: "codexBinaryPath", - 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", - binaryPathKey: "claudeBinaryPath", - 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, updateSettings, resetSettings, defaults } = useAppSettings(); - const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); - const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - copilot: false, - claudeAgent: Boolean(settings.claudeBinaryPath), - cursor: false, - opencode: false, - geminiCli: false, - amp: false, - kilo: false, - }); - const [customModelInputByProvider, setCustomModelInputByProvider] = useState< - Record - >({ - codex: "", - copilot: "", - claudeAgent: "", - cursor: "", - opencode: "", - geminiCli: "", - amp: "", - kilo: "", - }); - const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< - Partial> - >({}); - const [selectedCustomModelProvider, setSelectedCustomModelProvider] = - useState("codex"); - const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const refreshingRef = useRef(false); - const queryClient = useQueryClient(); - useRelativeTimeTick(); - - const [updateState, setUpdateState] = useState(null); - const [isCheckingUpdate, setIsCheckingUpdate] = useState(false); - - const [logDir, setLogDir] = useState(null); - const [logFiles, setLogFiles] = useState([]); - const [selectedLogFile, setSelectedLogFile] = useState(null); - const [logContent, setLogContent] = useState(""); - const [isLoadingLogs, setIsLoadingLogs] = useState(false); - const [isLogViewerOpen, setIsLogViewerOpen] = useState(false); - const logViewerRef = useRef(null); - - const loadLogFile = useCallback(async (filename: string) => { - setIsLoadingLogs(true); - try { - const api = ensureNativeApi(); - const result = await api.logs.read(filename); - setLogContent(result.content); - requestAnimationFrame(() => { - if (logViewerRef.current) { - logViewerRef.current.scrollTop = logViewerRef.current.scrollHeight; - } - }); - } catch { - setLogContent("Failed to read log file."); - } finally { - setIsLoadingLogs(false); - } - }, []); - - const hasDesktopBridge = isElectron && !!window.desktopBridge; - - useEffect(() => { - if (!hasDesktopBridge) return; - const bridge = window.desktopBridge!; - void bridge - .getUpdateState() - .then(setUpdateState) - .catch(() => {}); - const unsubscribe = bridge.onUpdateState(setUpdateState); - return unsubscribe; - }, [hasDesktopBridge]); - - useEffect(() => { - const api = ensureNativeApi(); - void api.logs - .getDir() - .then((result) => setLogDir(result.dir)) - .catch(() => {}); - }, []); - - const handleCheckForUpdate = useCallback(async () => { - if (!hasDesktopBridge) return; - setIsCheckingUpdate(true); - try { - const state = await window.desktopBridge!.checkForUpdate(); - setUpdateState(state); - } catch { - setUpdateState((prev) => - prev - ? { - ...prev, - status: "error", - message: "Failed to check for updates.", - errorContext: "check", - } - : prev, - ); - } finally { - setIsCheckingUpdate(false); - } - }, [hasDesktopBridge]); - - const handleDownloadUpdate = useCallback(async () => { - if (!hasDesktopBridge) return; - try { - const result = await window.desktopBridge!.downloadUpdate(); - setUpdateState(result.state); - } catch (error) { - setUpdateState((prev) => - prev - ? { - ...prev, - status: "error", - message: error instanceof Error ? error.message : "Failed to download update.", - errorContext: "download", - } - : prev, - ); - } - }, [hasDesktopBridge]); - - const handleInstallUpdate = useCallback(async () => { - if (!hasDesktopBridge) return; - try { - const result = await window.desktopBridge!.installUpdate(); - setUpdateState(result.state); - } catch (error) { - setUpdateState((prev) => - prev - ? { - ...prev, - status: "error", - message: error instanceof Error ? error.message : "Failed to install update.", - errorContext: "install", - } - : prev, - ); - } - }, [hasDesktopBridge]); - - 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 codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const accentColor = settings.accentColor; - const [presetNameInput, setPresetNameInput] = useState(null); - const presetNameRef = useRef(null); - const claudeBinaryPath = settings.claudeBinaryPath; - const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; - const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; - - const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( - (providerSettings) => providerSettings.provider === selectedCustomModelProvider, - )!; - const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; - const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - 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 totalCustomModels = savedCustomModelRows.length; - 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.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), - ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete - ? ["Delete confirmation"] - : []), - ...(totalCustomModels > 0 ? ["Custom models"] : []), - ...(isInstallSettingsDirty ? ["Provider installs"] : []), - ]; - - 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 = getCustomModelsForProvider(settings, provider); - 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(patchCustomModels(provider, [...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 = getCustomModelsForProvider(settings, provider); - updateSettings( - patchCustomModels( - provider, - customModels.filter((model) => model !== slug), - ), - ); - setCustomModelErrorByProvider((existing) => ({ - ...existing, - [provider]: null, - })); - }, - [settings, updateSettings], - ); - - 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(); - setOpenInstallProviders({ - codex: false, - copilot: false, - claudeAgent: false, - cursor: false, - opencode: false, - geminiCli: false, - amp: false, - kilo: false, - }); - setCustomModelInputByProvider({ - codex: "", - copilot: "", - claudeAgent: "", - cursor: "", - opencode: "", - geminiCli: "", - amp: "", - kilo: "", - }); - setCustomModelErrorByProvider({}); - } - - return ( - -
- {!isElectron && ( -
-
- - Settings -
- -
-
-
- )} - - {isElectron && ( -
- - Settings - -
- -
-
- )} - -
-
- - setTheme("system")} /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - timestampFormat: defaults.timestampFormat, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - diffWordWrap: defaults.diffWordWrap, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - diffWordWrap: Boolean(checked), - }) - } - aria-label="Wrap diff lines by default" - /> - } - /> - - - updateSettings({ - enableAssistantStreaming: defaults.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> - } - /> - - - updateSettings({ - defaultThreadEnvMode: defaults.defaultThreadEnvMode, - }) - } - /> - ) : null - } - control={ - - } - /> - - - updateSettings({ - confirmThreadDelete: defaults.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> - } - /> - - - - updateSettings({ accentColor: DEFAULT_ACCENT_COLOR })} - /> - ) : null - } - > -
-
- {ACCENT_COLOR_PRESETS.map((preset) => { - const selected = accentColor === preset.value; - return ( - - ); - })} - {settings.customAccentPresets.map((preset) => { - const selected = accentColor === preset.value; - return ( -
- - -
- ); - })} -
- -
- - - updateSettings({ accentColor: normalizeAccentColor(event.target.value) }) - } - /> - {accentColor} - - {presetNameInput === null ? ( - - ) : ( -
{ - e.preventDefault(); - const name = presetNameInput.trim(); - if (!name) return; - if ( - settings.customAccentPresets.some( - (p) => p.label.toLowerCase() === name.toLowerCase(), - ) - ) - return; - updateSettings({ - customAccentPresets: [ - ...settings.customAccentPresets, - { label: name, value: accentColor }, - ], - }); - setPresetNameInput(null); - }} - > - setPresetNameInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") setPresetNameInput(null); - }} - onBlur={() => { - if (!presetNameInput.trim()) setPresetNameInput(null); - }} - /> - -
- )} -
-
-
- - option.value === settings.providerLogoAppearance, - )?.description ?? "Use each provider's native logo colors." - } - resetAction={ - settings.providerLogoAppearance !== defaults.providerLogoAppearance ? ( - - updateSettings({ - providerLogoAppearance: defaults.providerLogoAppearance, - }) - } - /> - ) : null - } - control={ - - } - /> -
- - - {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { - const provider = providerSettings.provider; - const customModels = getCustomModelsForProvider(settings, provider); - const overrideModel = settings.gitTextGenerationModelByProvider[provider] ?? null; - const modelOptions = getAppModelOptions(provider, customModels, overrideModel); - const providerFallbackModel = - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER[provider]; - const providerFallbackLabel = - modelOptions.find((option) => option.slug === providerFallbackModel)?.name ?? - providerFallbackModel; - return ( - - updateSettings( - patchGitTextGenerationModelOverrides( - settings.gitTextGenerationModelByProvider, - provider, - value === GIT_TEXT_GENERATION_INHERIT_VALUE ? null : value, - ), - ) - } - > - - - - - - Use active thread model - - {modelOptions.map((option) => ( - - {option.name} - - ))} - - - } - /> - ); - })} - - - - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - customCopilotModels: defaults.customCopilotModels, - customCursorModels: defaults.customCursorModels, - customOpencodeModels: defaults.customOpencodeModels, - customGeminiCliModels: defaults.customGeminiCliModels, - customAmpModels: defaults.customAmpModels, - customKiloModels: defaults.customKiloModels, - }); - 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}

- ) : null} - - {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - -
- ))} -
- - {savedCustomModelRows.length > 5 ? ( - - ) : null} -
- ) : null} -
-
-
- - - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - copilot: false, - claudeAgent: false, - cursor: false, - opencode: false, - geminiCli: false, - amp: false, - kilo: 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, - })) - } - > -
- - - -
-
- - - {providerSettings.homePathKey ? ( - - ) : null} -
-
-
-
-
- ); - })} -
-
-
- - - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - {openKeybindingsError} - ) : ( - Opens in your preferred editor. - )} - - } - control={ - - } - /> - - -
- {logDir ? ( -
- - {logDir} - - {hasDesktopBridge ? ( - - ) : null} -
- ) : null} - - - - {isLogViewerOpen ? ( -
-
- - -
-
-                        {logContent ? (
-                          
-                        ) : (
-                          "No log content."
-                        )}
-                      
-
- ) : null} -
-
- - {APP_VERSION} - } - /> -
-
-
-
-
- ); -} - -export const Route = createFileRoute("/_chat/settings")({ - component: SettingsRouteView, -}); 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..45096fd6d6 --- /dev/null +++ b/apps/web/src/routes/settings.tsx @@ -0,0 +1,92 @@ +import { RotateCcwIcon } from "lucide-react"; +import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { useEffect, 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), + ); + + 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 ( + +
+ {!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.ts b/apps/web/src/store.ts index be6cba160a..a22d4da31a 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -638,6 +638,7 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea proposedPlans, error: thread.session?.lastError ?? null, createdAt: thread.createdAt, + archivedAt: thread.archivedAt ?? null, updatedAt: thread.updatedAt, latestTurn, lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 1e5fb39405..db0544fcef 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -43,6 +43,32 @@ describe("extractTerminalLinks", () => { }, ]); }); + + it("finds Windows absolute paths with forward slashes", () => { + const line = "see C:/Users/someone/project/src/file.ts:42 for details"; + const path = "C:/Users/someone/project/src/file.ts:42"; + const start = line.indexOf(path); + expect(extractTerminalLinks(line)).toEqual([ + { + kind: "path", + text: path, + start, + end: start + path.length, + }, + ]); + }); + + it("trims trailing punctuation from Windows forward-slash paths", () => { + const line = "(C:/tmp/x.ts)."; + expect(extractTerminalLinks(line)).toEqual([ + { + kind: "path", + text: "C:/tmp/x.ts", + start: 1, + end: 12, + }, + ]); + }); }); describe("resolvePathLinkTarget", () => { @@ -60,6 +86,12 @@ describe("resolvePathLinkTarget", () => { resolvePathLinkTarget("/Users/julius/project/src/main.ts:12", "/Users/julius/project"), ).toBe("/Users/julius/project/src/main.ts:12"); }); + + it("keeps Windows absolute paths with forward slashes unchanged", () => { + expect( + resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"), + ).toBe("C:/Users/julius/project/src/main.ts:12"); + }); }); describe("isTerminalLinkActivation", () => { diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1119fc9087..e07904e180 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -11,7 +11,7 @@ export interface TerminalLinkMatch { const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; const FILE_PATH_PATTERN = - /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; + /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; function trimClosingDelimiters(value: string): string { 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..cc3db45aca 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): string { + const { value, suffix } = formatRelativeTime(isoDate); + return suffix ? `${value} ${suffix}` : value; +} 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/apps/web/src/truncateTitle.ts b/apps/web/src/truncateTitle.ts index bce5545283..4cc3e12e08 100644 --- a/apps/web/src/truncateTitle.ts +++ b/apps/web/src/truncateTitle.ts @@ -3,5 +3,6 @@ export function truncateTitle(text: string, maxLength = 50): string { if (trimmed.length <= maxLength) { return trimmed; } + return `${trimmed.slice(0, maxLength)}...`; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index de06f95538..43ad91c886 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/bun.lock b/bun.lock index 6e6bb600e4..83ad09503f 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", }, @@ -76,7 +76,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", @@ -128,7 +128,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.14", + "version": "0.0.15", "dependencies": { "effect": "catalog:", }, @@ -173,17 +173,20 @@ }, }, }, + "trustedDependencies": [ + "node-pty", + ], "overrides": { "vite": "^8.0.0", }, "catalog": { - "@effect/language-service": "0.75.1", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "@effect/language-service": "0.84.1", + "@effect/platform-node": "4.0.0-beta.42", + "@effect/sql-sqlite-bun": "4.0.0-beta.42", + "@effect/vitest": "4.0.0-beta.42", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", + "effect": "4.0.0-beta.42", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -269,15 +272,15 @@ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], - "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], + "@effect/language-service": ["@effect/language-service@0.84.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-YUqjJU24HeYgPV453cR2fDqkZ+zZKMuxGnmxWAPscWJ6gt6FB7JZohMCOczRTIOGPrQMcloJX7BjCaPu+RNhpw=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.42", "", { "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-Ah2QfkeV+I9r5OBVJijSDnFXCv51giBXngSwhju5gefc0uWiM3G1tsYAqrNX24HlvFFEnOAZqNf/Sq1h4NqOAA=="], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.42", "", { "peerDependencies": { "effect": "^4.0.0-beta.42", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-/11arjUnCRhIrBRvOn/nrbg5p/FadjAPvStddZlpl1VrCxtB2s0n39cbG9uTyDdf1ZrRBG73Upo1ZDF1CTWy8w=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1039,7 +1042,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -1929,14 +1932,6 @@ "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@effect/platform-node/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/docs/effect-fn-checklist.md b/docs/effect-fn-checklist.md new file mode 100644 index 0000000000..0d28171aa2 --- /dev/null +++ b/docs/effect-fn-checklist.md @@ -0,0 +1,183 @@ +# Effect.fn Refactor Checklist + +Generated from a repo scan for non-test wrapper-style candidates matching either `=> Effect.gen(function* ...)` or `return Effect.gen(function* ...)`. + +Refactor Method: + +```ts +// Old +function old () { + return Effect.gen(function* () { + ... + }); +} + +const old2 = () => Effect.gen(function* () { + ... +}); +``` + +```ts +// New +const new = Effect.fn('functionName')(function* () { + ... +}) +``` + +## Summary + +- Total non-test candidates: `322` + +## Suggested Order + +- [ ] `apps/server/src/provider/Layers/ProviderService.ts` +- [x] `apps/server/src/provider/Layers/ClaudeAdapter.ts` +- [ ] `apps/server/src/provider/Layers/CodexAdapter.ts` +- [ ] `apps/server/src/git/Layers/GitCore.ts` +- [ ] `apps/server/src/git/Layers/GitManager.ts` +- [ ] `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` +- [ ] `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` +- [ ] `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` +- [ ] `apps/server/src/provider/Layers/EventNdjsonLogger.ts` +- [ ] `Everything else` + +## Checklist + +### `apps/server/src/provider/Layers/ClaudeAdapter.ts` (`62`) + +- [x] [buildUserMessageEffect](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L554) +- [x] [makeClaudeAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L913) +- [x] [startSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2414) +- [x] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2887) +- [x] [interruptTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2975) +- [x] [readThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2984) +- [x] [rollbackThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L2990) +- [x] [stopSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeAdapter.ts#L3039) +- [x] Internal helpers and callback wrappers in this file + +### `apps/server/src/git/Layers/GitCore.ts` (`58`) + +- [ ] [makeGitCore](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L495) +- [ ] [handleTraceLine](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L317) +- [ ] [emitCompleteLines](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L449) +- [ ] [commit](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1178) +- [ ] [pushCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1217) +- [ ] [pullCurrentBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1316) +- [ ] [checkoutBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitCore.ts#L1697) +- [ ] Service methods and callback wrappers in this file + +### `apps/server/src/git/Layers/GitManager.ts` (`28`) + +- [ ] [configurePullRequestHeadUpstream](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L387) +- [ ] [materializePullRequestHeadBranch](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L428) +- [ ] [findOpenPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L576) +- [ ] [findLatestPr](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L602) +- [ ] [runCommitStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L728) +- [ ] [runPrStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L842) +- [ ] [runFeatureBranchStep](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/GitManager.ts#L1106) +- [ ] Remaining helpers and nested callback wrappers in this file + +### `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` (`25`) + +- [ ] [runProjectorForEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L1161) +- [ ] [applyProjectsProjection](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L357) +- [ ] [applyThreadsProjection](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProjectionPipeline.ts#L415) +- [ ] `Effect.forEach(..., threadId => Effect.gen(...))` callbacks around `L250` +- [ ] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L264` +- [ ] `Effect.forEach(..., entry => Effect.gen(...))` callbacks around `L305` +- [ ] Remaining apply helpers in this file + +### `apps/server/src/provider/Layers/ProviderService.ts` (`24`) + +- [ ] [makeProviderService](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L134) +- [ ] [recoverSessionForThread](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L196) +- [ ] [resolveRoutableSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L255) +- [ ] [startSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L284) +- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L347) +- [ ] [interruptTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L393) +- [ ] [respondToRequest](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L411) +- [ ] [respondToUserInput](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L430) +- [ ] [stopSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L445) +- [ ] [listSessions](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L466) +- [ ] [rollbackConversation](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L516) +- [ ] [runStopAll](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderService.ts#L538) + +### `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts` (`14`) + +- [ ] [finalizeAssistantMessage](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L680) +- [ ] [upsertProposedPlan](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L722) +- [ ] [finalizeBufferedProposedPlan](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L761) +- [ ] [clearTurnStateForSession](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L800) +- [ ] [processRuntimeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts#L908) +- [ ] Nested callback wrappers in this file + +### `apps/server/src/provider/Layers/CodexAdapter.ts` (`12`) + +- [ ] [makeCodexAdapter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1317) +- [ ] [sendTurn](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1399) +- [ ] [writeNativeEvent](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1546) +- [ ] [listener](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexAdapter.ts#L1555) +- [ ] Remaining nested callback wrappers in this file + +### `apps/server/src/checkpointing/Layers/CheckpointStore.ts` (`10`) + +- [ ] [captureCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L89) +- [ ] [restoreCheckpoint](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L183) +- [ ] [diffCheckpoints](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L220) +- [ ] [deleteCheckpointRefs](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointStore.ts#L252) +- [ ] Nested callback wrappers in this file + +### `apps/server/src/provider/Layers/EventNdjsonLogger.ts` (`9`) + +- [ ] [toLogMessage](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L77) +- [ ] [makeThreadWriter](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L102) +- [ ] [makeEventNdjsonLogger](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L174) +- [ ] [write](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L231) +- [ ] [close](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/EventNdjsonLogger.ts#L247) +- [ ] Flush and writer-resolution callback wrappers in this file + +### `apps/server/scripts/cli.ts` (`8`) + +- [ ] Command handlers around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L125) +- [ ] Command handlers around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L170) +- [ ] Resource callbacks around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L221) +- [ ] Resource callbacks around [cli.ts](/Users/julius/Development/Work/codething-mvp/apps/server/scripts/cli.ts#L239) + +### `apps/server/src/orchestration/Layers/OrchestrationEngine.ts` (`7`) + +- [ ] [processEnvelope](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L64) +- [ ] [dispatch](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L218) +- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L162) +- [ ] Catch/stream callback wrappers around [OrchestrationEngine.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/OrchestrationEngine.ts#L200) + +### `apps/server/src/orchestration/projector.ts` (`5`) + +- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L242) +- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L336) +- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L397) +- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L446) +- [ ] `switch` branch wrapper at [projector.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/projector.ts#L478) + +### Smaller clusters + +- [ ] [packages/shared/src/DrainableWorker.ts](/Users/julius/Development/Work/codething-mvp/packages/shared/src/DrainableWorker.ts) (`4`) +- [ ] [apps/server/src/wsServer/pushBus.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/wsServer/pushBus.ts) (`4`) +- [ ] [apps/server/src/wsServer.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/wsServer.ts) (`4`) +- [ ] [apps/server/src/provider/Layers/ProviderRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderRegistry.ts) (`4`) +- [ ] [apps/server/src/persistence/Layers/Sqlite.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Layers/Sqlite.ts) (`4`) +- [ ] [apps/server/src/orchestration/Layers/ProviderCommandReactor.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) (`4`) +- [ ] [apps/server/src/main.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/main.ts) (`4`) +- [ ] [apps/server/src/keybindings.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/keybindings.ts) (`4`) +- [ ] [apps/server/src/git/Layers/CodexTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/CodexTextGeneration.ts) (`4`) +- [ ] [apps/server/src/serverLayers.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/serverLayers.ts) (`3`) +- [ ] [apps/server/src/telemetry/Layers/AnalyticsService.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/telemetry/Layers/AnalyticsService.ts) (`2`) +- [ ] [apps/server/src/telemetry/Identify.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/telemetry/Identify.ts) (`2`) +- [ ] [apps/server/src/provider/Layers/ProviderAdapterRegistry.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts) (`2`) +- [ ] [apps/server/src/provider/Layers/CodexProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/CodexProvider.ts) (`2`) +- [ ] [apps/server/src/provider/Layers/ClaudeProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/Layers/ClaudeProvider.ts) (`2`) +- [ ] [apps/server/src/persistence/NodeSqliteClient.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/NodeSqliteClient.ts) (`2`) +- [ ] [apps/server/src/persistence/Migrations.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/persistence/Migrations.ts) (`2`) +- [ ] [apps/server/src/open.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/open.ts) (`2`) +- [ ] [apps/server/src/git/Layers/ClaudeTextGeneration.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/git/Layers/ClaudeTextGeneration.ts) (`2`) +- [ ] [apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts) (`2`) +- [ ] [apps/server/src/provider/makeManagedServerProvider.ts](/Users/julius/Development/Work/codething-mvp/apps/server/src/provider/makeManagedServerProvider.ts) (`1`) diff --git a/package.json b/package.json index 76b4f42af9..99b747ea01 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", - "@effect/language-service": "0.75.1", + "effect": "4.0.0-beta.42", + "@effect/platform-node": "4.0.0-beta.42", + "@effect/sql-sqlite-bun": "4.0.0-beta.42", + "@effect/vitest": "4.0.0-beta.42", + "@effect/language-service": "0.84.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", "tsdown": "^0.20.3", @@ -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", @@ -71,5 +72,8 @@ "workerDirectory": [ "apps/web/public" ] - } + }, + "trustedDependencies": [ + "node-pty" + ] } 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" diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b5b8ba0b28..f837c3c1e1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -67,6 +67,7 @@ export interface ContextMenuItem { id: T; label: string; destructive?: boolean; + disabled?: boolean; } export type DesktopUpdateStatus = diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index db9de7a14d..4de407b865 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -52,6 +52,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"); }), ); @@ -126,8 +132,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 6c16e94c9d..2f3ddc7048 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 = [ "commandPalette.toggle", "terminal.toggle", @@ -17,6 +37,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + ...THREAD_KEYBINDING_COMMANDS, ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d3944c4e8a..5b3d493211 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -462,6 +462,9 @@ export const OrchestrationThread = Schema.Struct({ worktreePath: Schema.NullOr(TrimmedNonEmptyString), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, + archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)).pipe( + Schema.withDecodingDefault(() => null), + ), updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), @@ -528,6 +531,20 @@ const ThreadDeleteCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadArchiveCommand = Schema.Struct({ + type: Schema.Literal("thread.archive"), + commandId: CommandId, + threadId: ThreadId, + createdAt: IsoDateTime, +}); + +const ThreadUnarchiveCommand = Schema.Struct({ + type: Schema.Literal("thread.unarchive"), + commandId: CommandId, + threadId: ThreadId, + createdAt: IsoDateTime, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -637,6 +654,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, + ThreadArchiveCommand, + ThreadUnarchiveCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -656,6 +675,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectDeleteCommand, ThreadCreateCommand, ThreadDeleteCommand, + ThreadArchiveCommand, + ThreadUnarchiveCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -757,6 +778,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", @@ -823,6 +846,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), @@ -984,6 +1018,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 3e327c27cf..ba27dc21ea 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -30,6 +30,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( @@ -80,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; @@ -218,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)), }); diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index be9e6ebd55..79943f78bc 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -72,20 +72,16 @@ export const makeDrainableWorker = ( ), ); - yield* Effect.forkScoped( - Effect.forever( - Queue.take(queue).pipe( - Effect.flatMap((item) => - process(item).pipe( - Effect.catchCause((cause) => - Effect.logWarning("drainable worker item failed", cause), - ), - Effect.ensuring(finishOne), - ), + yield* Effect.forever( + Queue.take(queue).pipe( + Effect.flatMap((item) => + process(item).pipe( + Effect.catchCause((cause) => Effect.logWarning("drainable worker item failed", cause)), + Effect.ensuring(finishOne), ), ), ), - ); + ).pipe(Effect.forkScoped({ startImmediately: true })); const enqueue: DrainableWorker
["enqueue"] = (item) => Effect.gen(function* () { 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/packages/shared/src/String.ts b/packages/shared/src/String.ts new file mode 100644 index 0000000000..c93d0c90cb --- /dev/null +++ b/packages/shared/src/String.ts @@ -0,0 +1,8 @@ +export function truncate(text: string, maxLength = 50): string { + const trimmed = text.trim(); + if (trimmed.length <= maxLength) { + return trimmed; + } + + return `${trimmed.slice(0, maxLength)}...`; +} 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; 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}`);