diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index cda78a20..7d457889 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -6,11 +6,12 @@ describe("syncShellEnvironment", () => { it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readEnvironment = vi.fn(() => ({ PATH: "/opt/homebrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", + HOMEBREW_PREFIX: "/opt/homebrew", })); syncShellEnvironment(env, { @@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); - expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); + expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); }); it("preserves an inherited SSH_AUTH_SOCK value", () => { @@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => { readEnvironment, }); - expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); }); + it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readEnvironment = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => ({})); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + syncShellEnvironment(env, { + platform: "darwin", + readEnvironment, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ]); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read login shell environment from /opt/homebrew/bin/nu.", + expect.any(Error), + ); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); + }); + it("does nothing outside macOS and linux", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 13036149..7e031b11 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,36 +1,79 @@ import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, readEnvironmentFromLoginShell, - resolveLoginShell, ShellEnvironmentReader, } from "@t3tools/shared/shell"; +const LOGIN_SHELL_ENV_NAMES = [ + "PATH", + "SSH_AUTH_SOCK", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", +] as const; + +function logShellEnvironmentWarning(message: string, error?: unknown): void { + console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} + export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin" && platform !== "linux") return; - try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; + const logWarning = options.logWarning ?? logShellEnvironmentWarning; + const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; + const shellEnvironment: Partial> = {}; - const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ - "PATH", - "SSH_AUTH_SOCK", - ]); + try { + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); + if (shellEnvironment.PATH) { + break; + } + } catch (error) { + logWarning(`Failed to read login shell environment from ${shell}.`, error); + } + } - if (shellEnvironment.PATH) { - env.PATH = shellEnvironment.PATH; + const launchctlPath = + platform === "darwin" && !shellEnvironment.PATH + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } - } catch { - // Keep inherited environment if shell lookup fails. + + for (const name of [ + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "HOMEBREW_REPOSITORY", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + ] as const) { + if (!env[name] && shellEnvironment[name]) { + env[name] = shellEnvironment[name]; + } + } + } catch (error) { + logWarning("Failed to synchronize the desktop shell environment.", error); } } diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts new file mode 100644 index 00000000..8b223c3b --- /dev/null +++ b/apps/server/src/atomicWrite.ts @@ -0,0 +1,25 @@ +import { Effect, FileSystem, Path } from "effect"; +import * as Random from "effect/Random"; + +export const writeFileStringAtomically = (input: { + readonly filePath: string; + readonly contents: string; +}) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempFileId = yield* Random.nextUUIDv4; + const targetDirectory = path.dirname(input.filePath); + + yield* fileSystem.makeDirectory(targetDirectory, { recursive: true }); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.filePath)}.`, + }); + const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`); + + yield* fileSystem.writeFileString(tempPath, input.contents); + yield* fileSystem.rename(tempPath, input.filePath); + }), + ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 64d747e8..7823a808 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -1,5 +1,3 @@ -import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; @@ -8,11 +6,32 @@ import { NetService } from "@t3tools/shared/Net"; import { cli } from "./cli"; import { version } from "../package.json" with { type: "json" }; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const isBunRuntime = typeof Bun !== "undefined"; -const main = (Command.run(cli, { version }) as any).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), -); +const main = (Command.run(cli, { version }) as any).pipe(Effect.scoped); -NodeRuntime.runMain(main); +async function bootCli(): Promise { + // Keep runtime selection dynamic so the same source can boot under Bun for + // local development and Node for packaged builds, without forcing the CJS + // build to support top-level await. + if (isBunRuntime) { + const [BunRuntime, BunServices] = await Promise.all([ + import("@effect/platform-bun/BunRuntime"), + import("@effect/platform-bun/BunServices"), + ]); + const cliRuntimeLayer = Layer.mergeAll(BunServices.layer, NetService.layer); + + BunRuntime.runMain(main.pipe(Effect.provide(cliRuntimeLayer))); + return; + } + + const [NodeRuntime, NodeServices] = await Promise.all([ + import("@effect/platform-node/NodeRuntime"), + import("@effect/platform-node/NodeServices"), + ]); + const cliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + + NodeRuntime.runMain(main.pipe(Effect.provide(cliRuntimeLayer))); +} + +void bootCli(); diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts index e81e5093..4378d2b4 100644 --- a/apps/server/src/checkpointing/Diffs.test.ts +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -65,4 +65,39 @@ describe("parseTurnDiffFilesFromUnifiedDiff", () => { { path: "a.txt", additions: 2, deletions: 1 }, ]); }); + + it("parses quoted diff headers for paths that need escaping", () => { + const diff = [ + 'diff --git "a/src/with space.ts" "b/src/with space.ts"', + "index 1111111..2222222 100644", + '--- "a/src/with space.ts"', + '+++ "b/src/with space.ts"', + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"); + + expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ + { path: "src/with space.ts", additions: 1, deletions: 1 }, + ]); + }); + + it("does not treat added +++ content as a path header", () => { + const diff = [ + "diff --git a/a.txt b/a.txt", + "index 1111111..2222222 100644", + "--- a/a.txt", + "+++ b/a.txt", + "@@ -1 +1,2 @@", + " unchanged", + "+hello", + "+++ counter", + "", + ].join("\n"); + + expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ + { path: "a.txt", additions: 2, deletions: 0 }, + ]); + }); }); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index c2f867b9..23d323ec 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,11 +1,134 @@ -import { parsePatchFiles } from "@pierre/diffs"; - export interface TurnDiffFileSummary { readonly path: string; readonly additions: number; readonly deletions: number; } +interface MutableTurnDiffFileSummary { + path: string; + additions: number; + deletions: number; +} + +interface MutableTurnDiffFileState extends MutableTurnDiffFileSummary { + inHeader: boolean; +} + +function normalizeDiffPath(rawPath: string): string { + return rawPath.replace(/^b\//, "").trim(); +} + +function finalizeFile( + summaries: Array, + current: MutableTurnDiffFileState | null, +): MutableTurnDiffFileState | null { + if (!current || current.path.length === 0) { + return null; + } + + summaries.push({ + path: current.path, + additions: current.additions, + deletions: current.deletions, + }); + return null; +} + +function decodeGitQuotedPathToken(value: string): string | null { + let decoded = ""; + + for (let index = 1; index < value.length; index += 1) { + const current = value[index]; + if (current === '"') { + return decoded; + } + if (current !== "\\") { + decoded += current; + continue; + } + + const escape = value[index + 1]; + if (!escape) { + return null; + } + if (/^[0-7]{3}$/.test(value.slice(index + 1, index + 4))) { + decoded += String.fromCharCode(Number.parseInt(value.slice(index + 1, index + 4), 8)); + index += 3; + continue; + } + + decoded += escape === "t" ? "\t" : escape === "n" ? "\n" : escape === "r" ? "\r" : escape; + index += 1; + } + + return null; +} + +function readGitPathToken(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + if (!trimmed.startsWith('"')) { + return trimmed.split(/\s+/, 1)[0] ?? null; + } + + return decodeGitQuotedPathToken(trimmed); +} + +function parseDiffGitHeaderPath(line: string): string | null { + const prefix = "diff --git "; + if (!line.startsWith(prefix)) { + return null; + } + + const value = line.slice(prefix.length); + let index = 0; + let tokenCount = 0; + + while (index < value.length) { + while (value[index] === " ") { + index += 1; + } + if (index >= value.length) { + break; + } + + const rest = value.slice(index); + const token = readGitPathToken(rest); + if (token === null) { + return null; + } + tokenCount += 1; + if (tokenCount === 2) { + return normalizeDiffPath(token); + } + + if (rest.startsWith('"')) { + index += 1; + while (index < value.length) { + if (value[index] === "\\") { + index += 2; + continue; + } + if (value[index] === '"') { + index += 1; + break; + } + index += 1; + } + continue; + } + + while (index < value.length && value[index] !== " ") { + index += 1; + } + } + + return null; +} + export function parseTurnDiffFilesFromUnifiedDiff( diff: string, ): ReadonlyArray { @@ -14,14 +137,62 @@ export function parseTurnDiffFilesFromUnifiedDiff( return []; } - const parsedPatches = parsePatchFiles(normalized); - const files = parsedPatches.flatMap((patch) => - patch.files.map((file) => ({ - path: file.name, - additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0), - deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0), - })), - ); + const summaries: Array = []; + let current: MutableTurnDiffFileState | null = null; + + for (const line of normalized.split("\n")) { + if (line.startsWith("diff --git ")) { + current = finalizeFile(summaries, current); + current = { + // Diff headers can quote paths with C-style escapes. Parse the tokens + // instead of assuming plain `a/foo b/foo` text so files with spaces, + // tabs, or quotes still survive long enough to be finalized. + path: parseDiffGitHeaderPath(line) ?? "", + additions: 0, + deletions: 0, + inHeader: true, + }; + continue; + } + + if (!current) { + continue; + } + + if (current.inHeader && line.startsWith("rename to ")) { + const nextPath = readGitPathToken(line.slice("rename to ".length)); + if (nextPath) { + current.path = normalizeDiffPath(nextPath); + } + continue; + } + + if (current.inHeader && line.startsWith("+++ ")) { + const nextPath = readGitPathToken(line.slice(4)); + if (nextPath && nextPath !== "/dev/null") { + current.path = normalizeDiffPath(nextPath); + } + continue; + } + + if (line.startsWith("@@") || line.startsWith("Binary files ") || line === "GIT binary patch") { + // Only treat `rename to` / `+++` as metadata while we are still in the + // patch header. Once hunks begin, lines such as `+++ counter` are real + // file content and must be counted as additions instead of path updates. + current.inHeader = false; + } + + if (line.startsWith("+") && !(current.inHeader && line.startsWith("+++ "))) { + current.additions += 1; + continue; + } + + if (line.startsWith("-") && !(current.inHeader && line.startsWith("--- "))) { + current.deletions += 1; + } + } + + finalizeFile(summaries, current); - return files.toSorted((left, right) => left.path.localeCompare(right.path)); + return summaries.toSorted((left, right) => left.path.localeCompare(right.path)); } diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index b56961e2..26149c28 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -315,6 +315,20 @@ describe("readCodexAccountSnapshot", () => { }); }); + it("keeps spark disabled for chatgpt prolite accounts", () => { + expect( + readCodexAccountSnapshot({ + type: "chatgpt", + email: "prolite@example.com", + planType: "prolite", + }), + ).toEqual({ + type: "chatgpt", + planType: "prolite", + sparkEnabled: false, + }); + }); + it("disables spark for api key accounts", () => { expect( readCodexAccountSnapshot({ diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index e956e648..fc39b537 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -32,6 +32,7 @@ import { type CodexAccountSnapshot, } from "./provider/codexAccount"; import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; +import { expandHomePath } from "./pathExpansion"; export { buildCodexInitializeParams } from "./provider/codexAppServer"; export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; @@ -505,7 +506,7 @@ export class CodexAppServerManager extends EventEmitter { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); + }); +} + +function makeDirectory( + dirPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); + }); +} + /** Run a raw git command for test setup (not under test). */ function git( cwd: string, @@ -304,6 +322,21 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("returns isRepo: false for deleted directories", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const deletedDir = path.join(tmp, "deleted-repo"); + yield* makeDirectory(deletedDir); + yield* removePath(deletedDir); + + const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); + + expect(result.isRepo).toBe(false); + expect(result.hasOriginRemote).toBe(false); + expect(result.branches).toEqual([]); + }), + ); + it.effect("returns the current branch with current: true", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -798,7 +831,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("shares upstream refreshes across worktrees that use the same git common dir", () => + it.effect("refreshes each tracked upstream ref even when siblings share one remote", () => Effect.gen(function* () { const ok = (stdout = "") => Effect.succeed({ @@ -817,7 +850,9 @@ it.layer(TestLayer)("git integration", (it) => { input.args[2] === "--symbolic-full-name" && input.args[3] === "@{upstream}" ) { - return ok("origin/main\n"); + return ok( + input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", + ); } if (input.args[0] === "remote") { return ok("origin\n"); @@ -828,10 +863,34 @@ it.layer(TestLayer)("git integration", (it) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { fetchCount += 1; expect(input.cwd).toBe("/repo"); + expect([ + [ + "--git-dir", + "/repo/.git", + "fetch", + "--quiet", + "--no-tags", + "origin", + "+refs/heads/main:refs/remotes/origin/main", + ], + [ + "--git-dir", + "/repo/.git", + "fetch", + "--quiet", + "--no-tags", + "origin", + "+refs/heads/feature/pr-123:refs/remotes/origin/feature/pr-123", + ], + ]).toContainEqual(input.args); return ok(); } if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + return ok( + input.cwd === "/repo/worktrees/pr-123" + ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" + : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", + ); } if ( input.operation === "GitCore.statusDetails.unstagedNumstat" || @@ -854,11 +913,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* core.statusDetails("/repo/worktrees/main"); yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); + expect(fetchCount).toBe(2); }), ); - it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => + it.effect("briefly backs off failed refreshes per tracked upstream ref on one remote", () => Effect.gen(function* () { const ok = (stdout = "") => Effect.succeed({ @@ -877,7 +936,9 @@ it.layer(TestLayer)("git integration", (it) => { input.args[2] === "--symbolic-full-name" && input.args[3] === "@{upstream}" ) { - return ok("origin/main\n"); + return ok( + input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", + ); } if (input.args[0] === "remote") { return ok("origin\n"); @@ -897,7 +958,11 @@ it.layer(TestLayer)("git integration", (it) => { ); } if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + return ok( + input.cwd === "/repo/worktrees/pr-123" + ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" + : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", + ); } if ( input.operation === "GitCore.statusDetails.unstagedNumstat" || @@ -920,7 +985,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* core.statusDetails("/repo/worktrees/main"); yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); + expect(fetchCount).toBe(2); }), ); @@ -1631,6 +1696,37 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("returns a non-repo status for deleted directories", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const deletedDir = path.join(tmp, "deleted-repo"); + yield* makeDirectory(deletedDir); + yield* removePath(deletedDir); + const core = yield* GitCore; + + const status = yield* core.statusDetails(deletedDir); + const localStatus = yield* core.statusDetailsLocal(deletedDir); + + expect(status).toEqual({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + }); + expect(localStatus).toEqual(status); + }), + ); + it.effect("computes ahead count against base branch when no upstream is configured", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index ab783980..a83142ce 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -27,6 +27,7 @@ import { type ExecuteGitProgress, type GitCommitOptions, type GitCoreShape, + type GitStatusDetails, type ExecuteGitInput, type ExecuteGitResult, } from "../Services/GitCore.ts"; @@ -60,14 +61,30 @@ const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const STATUS_UPSTREAM_REFRESH_SSH_CONNECT_TIMEOUT_SECONDS = 1; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, +}); type TraceTailState = { processedChars: number; remainder: string; }; -class StatusUpstreamRefreshCacheKey extends Data.Class<{ +class StatusRemoteRefreshCacheKey extends Data.Class<{ gitCommonDir: string; + // The cache must stay branch-specific. Reusing one remote-level refresh + // entry across sibling worktrees can make a fetch for `origin/main` appear + // to satisfy `origin/feature/...`, even though only one tracking ref was + // updated. upstreamRef: string; remoteName: string; upstreamBranch: string; @@ -374,6 +391,16 @@ function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; } +function isMissingGitCwdError(error: GitCommandError): boolean { + const normalized = `${error.detail}\n${error.message}`.toLowerCase(); + return ( + normalized.includes("no such file or directory") || + normalized.includes("notfound: filesystem.access") || + normalized.includes("enoent") || + normalized.includes("not a directory") + ); +} + function toGitCommandError( input: Pick, detail: string, @@ -926,15 +953,19 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); - const fetchUpstreamRefForStatus = ( + const fetchRemoteForStatus = ( gitCommonDir: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect => { + // Status refreshes need to update the exact tracking ref for the current + // branch. A bare `git fetch ` can skip that ref in single-branch + // clones or repos with custom remote..fetch rules, which leaves + // ahead/behind counts stale even though we just "refreshed". const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( - "GitCore.fetchUpstreamRefForStatus", + "GitCore.fetchRemoteForStatus", fetchCwd, ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { @@ -953,10 +984,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); }); - const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( - cacheKey: StatusUpstreamRefreshCacheKey, + const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( + cacheKey: StatusRemoteRefreshCacheKey, ) { - yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, { + yield* fetchRemoteForStatus(cacheKey.gitCommonDir, { upstreamRef: cacheKey.upstreamRef, remoteName: cacheKey.remoteName, upstreamBranch: cacheKey.upstreamBranch, @@ -964,9 +995,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return true as const; }); - const statusUpstreamRefreshCache = yield* Cache.makeWith({ + const statusRemoteRefreshCache = yield* Cache.makeWith({ capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - lookup: refreshStatusUpstreamCacheEntry, + lookup: refreshStatusRemoteCacheEntry, // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. timeToLive: (exit) => Exit.isSuccess(exit) @@ -987,9 +1018,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( - statusUpstreamRefreshCache, - new StatusUpstreamRefreshCacheKey({ + statusRemoteRefreshCache, + new StatusRemoteRefreshCacheKey({ gitCommonDir, + // Keep the cache scoped to the exact upstream ref. Sibling worktrees on + // the same remote can track different branches, and sharing one remote- + // keyed entry would let a refresh for `origin/main` incorrectly satisfy + // a later `origin/feature/...` status request. upstreamRef: upstream.upstreamRef, remoteName: upstream.remoteName, upstreamBranch: upstream.upstreamBranch, @@ -1228,7 +1263,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { { allowNonZeroExit: true, }, - ); + ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } if (statusResult.code !== 0) { const stderr = statusResult.stderr.trim(); @@ -1360,7 +1399,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); return yield* readStatusDetailsLocal(cwd); }); @@ -1757,6 +1799,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, allowNonZeroExit: true, }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + code: 128, + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), ); if (localBranchResult.code !== 0) { diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts index aafc796d..0ee4b3f0 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { }), ); + it.effect("trims pull request fields decoded from gh json", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: " Add PR thread creation \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", + baseRefName: " main ", + headRefName: "\tfeature/pr-threads\t", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: " octocat/codething-mvp ", + }, + headRepositoryOwner: { + login: " octocat ", + }, + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + }), + ); + + it.effect("skips invalid entries when parsing pr lists", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 43, + title: " Valid PR ", + url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", + baseRefName: " main ", + headRefName: " feature/pr-list ", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/pr-list", + }); + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/pr-list", + state: "open", + }, + ]); + }), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockedRunProcess.mockResolvedValueOnce({ diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 280679e3..75dfe0f3 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,5 +1,5 @@ -import { Effect, Layer, Schema } from "effect"; -import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { Effect, Layer, Result, Schema } from "effect"; +import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner"; import { GitHubCliError } from "@t3tools/contracts"; @@ -7,8 +7,12 @@ import { GitHubCli, type GitHubRepositoryCloneUrls, type GitHubCliShape, - type GitHubPullRequestSummary, } from "../Services/GitHubCli.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "../githubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown }); } -function normalizePullRequestState(input: { - state?: string | null | undefined; - mergedAt?: string | null | undefined; -}): "open" | "closed" | "merged" { - const mergedAt = input.mergedAt; - const state = input.state; - if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { - return "merged"; - } - if (state === "CLOSED") { - return "closed"; - } - return "open"; -} - -const RawGitHubPullRequestSchema = Schema.Struct({ - number: PositiveInt, - title: TrimmedNonEmptyString, - url: TrimmedNonEmptyString, - baseRefName: TrimmedNonEmptyString, - headRefName: TrimmedNonEmptyString, - state: Schema.optional(Schema.NullOr(Schema.String)), - mergedAt: Schema.optional(Schema.NullOr(Schema.String)), - isCrossRepository: Schema.optional(Schema.Boolean), - headRepository: Schema.optional( - Schema.NullOr( - Schema.Struct({ - nameWithOwner: Schema.String, - }), - ), - ), - headRepositoryOwner: Schema.optional( - Schema.NullOr( - Schema.Struct({ - login: Schema.String, - }), - ), - ), -}); - const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, }); -function normalizePullRequestSummary( - raw: Schema.Schema.Type, -): GitHubPullRequestSummary { - const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null; - const headRepositoryOwnerLogin = - raw.headRepositoryOwner?.login ?? - (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") - ? (headRepositoryNameWithOwner.split("/")[0] ?? null) - : null); - return { - number: raw.number, - title: raw.title, - url: raw.url, - baseRefName: raw.baseRefName, - headRefName: raw.headRefName, - state: normalizePullRequestState(raw), - ...(typeof raw.isCrossRepository === "boolean" - ? { isCrossRepository: raw.isCrossRepository } - : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }; -} - function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { Effect.flatMap((raw) => raw.length === 0 ? Effect.succeed([]) - : decodeGitHubJson( - raw, - Schema.Array(RawGitHubPullRequestSchema), - "listOpenPullRequests", - "GitHub CLI returned invalid PR list JSON.", + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "listOpenPullRequests", + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), + ); + }), ), ), - Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), ), getPullRequest: (input) => execute({ @@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubPullRequestSchema, - "getPullRequest", - "GitHub CLI returned invalid pull request JSON.", + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "getPullRequest", + detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), + ); + }), ), ), - Effect.map(normalizePullRequestSummary), ), getRepositoryCloneUrls: (input) => execute({ diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 99d6bbd9..b666a5b0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -198,6 +198,24 @@ function makeTempDir( }); } +function removePath( + targetPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); + }); +} + +function makeDirectory( + dirPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); + }); +} + function runGit( cwd: string, args: readonly string[], @@ -722,6 +740,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status trims PR metadata returned by gh before publishing it", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 14, + title: " Existing PR title \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", + baseRefName: " main ", + headRefName: "\tfeature/status-trimmed-pr\t", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 14, + title: "Existing PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/14", + baseBranch: "main", + headBranch: "feature/status-trimmed-pr", + state: "open", + }); + }), + ); + + it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 15, + title: " Valid PR title ", + url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", + baseRefName: " main ", + headRefName: "\tfeature/status-valid-pr-entry\t", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 15, + title: "Valid PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/15", + baseBranch: "main", + headBranch: "feature/status-valid-pr-entry", + state: "open", + }); + }), + ); + + it.effect("status preserves lowercase merged and closed PR states from gh json", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 16, + title: "Closed PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/16", + baseRefName: "main", + headRefName: "feature/status-lowercase-state", + state: "closed", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + number: 17, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/17", + baseRefName: "main", + headRefName: "feature/status-lowercase-state", + state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 17, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/17", + baseBranch: "main", + headBranch: "feature/status-lowercase-state", + state: "merged", + }); + }), + ); + it.effect("status returns an explicit non-repo result for non-git directories", () => Effect.gen(function* () { const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); @@ -748,6 +904,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status returns an explicit non-repo result for deleted directories", () => + Effect.gen(function* () { + const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); + const cwd = path.join(rootDir, "deleted-repo"); + yield* makeDirectory(cwd); + yield* removePath(cwd); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }), + ); + it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 9cdaceb1..31c86c02 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,7 +1,18 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; +import { + Cache, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Ref, + Result, +} from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, @@ -35,6 +46,10 @@ import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScr import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; +import { + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "../githubPullRequests.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -241,85 +256,6 @@ function matchesBranchHeadContext( return true; } -function parsePullRequestList(raw: unknown): PullRequestInfo[] { - if (!Array.isArray(raw)) return []; - - const parsed: PullRequestInfo[] = []; - for (const entry of raw) { - if (!entry || typeof entry !== "object") continue; - const record = entry as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - const state = record.state; - const mergedAt = record.mergedAt; - const updatedAt = record.updatedAt; - const isCrossRepository = record.isCrossRepository; - const headRepositoryRecord = - typeof record.headRepository === "object" && record.headRepository !== null - ? (record.headRepository as Record) - : null; - const headRepositoryOwnerRecord = - typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null - ? (record.headRepositoryOwner as Record) - : null; - const headRepositoryNameWithOwner = - typeof record.headRepositoryNameWithOwner === "string" - ? record.headRepositoryNameWithOwner - : typeof headRepositoryRecord?.nameWithOwner === "string" - ? headRepositoryRecord.nameWithOwner - : null; - const headRepositoryOwnerLogin = - typeof record.headRepositoryOwnerLogin === "string" - ? record.headRepositoryOwnerLogin - : typeof headRepositoryOwnerRecord?.login === "string" - ? headRepositoryOwnerRecord.login - : null; - if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { - continue; - } - if ( - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - continue; - } - - let normalizedState: "open" | "closed" | "merged"; - if ( - (typeof mergedAt === "string" && mergedAt.trim().length > 0) || - state === "MERGED" || - state === "merged" - ) { - normalizedState = "merged"; - } else if (state === "OPEN" || state === "open" || state === undefined || state === null) { - normalizedState = "open"; - } else if (state === "CLOSED" || state === "closed") { - normalizedState = "closed"; - } else { - continue; - } - - parsed.push({ - number, - title, - url, - baseRefName, - headRefName, - state: normalizedState, - updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, - ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }); - } - return parsed; -} - function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { return { number: summary.number, @@ -950,13 +886,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { continue; } - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), - }); + const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + gitManagerError( + "findLatestPr", + `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + decoded.failure, + ), + ); + } + + return Effect.succeed(decoded.success); + }), + ); - for (const pr of parsePullRequestList(parsedJson)) { + for (const pr of pullRequests) { if (!matchesBranchHeadContext(pr, headContext)) { continue; } diff --git a/apps/server/src/git/githubPullRequests.ts b/apps/server/src/git/githubPullRequests.ts new file mode 100644 index 00000000..d137a46d --- /dev/null +++ b/apps/server/src/git/githubPullRequests.ts @@ -0,0 +1,128 @@ +import { Cause, Exit, Result, Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +export interface NormalizedGitHubPullRequestRecord { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +const GitHubPullRequestSchema = Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + baseRefName: TrimmedNonEmptyString, + headRefName: TrimmedNonEmptyString, + state: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.optional(Schema.NullOr(Schema.String)), + isCrossRepository: Schema.optional(Schema.Boolean), + headRepository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nameWithOwner: Schema.String, + }), + ), + ), + headRepositoryOwner: Schema.optional( + Schema.NullOr( + Schema.Struct({ + login: Schema.String, + }), + ), + ), +}); + +function trimOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeGitHubPullRequestState(input: { + state?: string | null | undefined; + mergedAt?: string | null | undefined; +}): "open" | "closed" | "merged" { + const normalizedState = input.state?.trim().toUpperCase(); + if ( + (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || + normalizedState === "MERGED" + ) { + return "merged"; + } + if (normalizedState === "CLOSED") { + return "closed"; + } + return "open"; +} + +function normalizeGitHubPullRequestRecord( + raw: Schema.Schema.Type, +): NormalizedGitHubPullRequestRecord { + const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); + const headRepositoryOwnerLogin = + trimOptionalString(raw.headRepositoryOwner?.login) ?? + (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") + ? (headRepositoryNameWithOwner.split("/")[0] ?? null) + : null); + + return { + number: raw.number, + title: raw.title, + url: raw.url, + baseRefName: raw.baseRefName, + headRefName: raw.headRefName, + state: normalizeGitHubPullRequestState(raw), + updatedAt: + typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null, + ...(typeof raw.isCrossRepository === "boolean" + ? { isCrossRepository: raw.isCrossRepository } + : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); +const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); +const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); + +export const formatGitHubJsonDecodeError = formatSchemaError; + +export function decodeGitHubPullRequestListJson( + raw: string, +): Result.Result< + ReadonlyArray, + Cause.Cause +> { + const result = decodeGitHubPullRequestList(raw); + if (Result.isSuccess(result)) { + const pullRequests: NormalizedGitHubPullRequestRecord[] = []; + for (const entry of result.success) { + const decodedEntry = decodeGitHubPullRequestEntry(entry); + if (Exit.isFailure(decodedEntry)) { + continue; + } + pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); + } + return Result.succeed(pullRequests); + } + return Result.fail(result.failure); +} + +export function decodeGitHubPullRequestJson( + raw: string, +): Result.Result> { + const result = decodeGitHubPullRequest(raw); + if (Result.isSuccess(result)) { + return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); + } + return Result.fail(result.failure); +} diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 41c4d76f..f9c1db33 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,4 +1,3 @@ -import Mime from "@effect/platform-node/Mime"; import { Data, Effect, FileSystem, Layer, Option, Path } from "effect"; import { cast } from "effect/Function"; import { @@ -20,6 +19,7 @@ import { import { resolveAttachmentPathById } from "./attachmentStore"; import { ServerConfig } from "./config"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; +import { getContentTypeFromFilePath } from "./mime.ts"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; @@ -270,7 +270,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( }); } - const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const contentType = getContentTypeFromFilePath(filePath); const data = yield* fileSystem .readFile(filePath) .pipe(Effect.catch(() => Effect.succeed(null))); diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts index ceec2827..2001066b 100644 --- a/apps/server/src/imageMime.test.ts +++ b/apps/server/src/imageMime.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; +import { getContentTypeFromFilePath } from "./mime.ts"; describe("imageMime", () => { it("parses base64 data URL with mime type", () => { @@ -35,4 +36,14 @@ describe("imageMime", () => { it("does not read inherited keys from mime extension map", () => { expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); }); + + it("returns a content type for common static assets", () => { + expect(getContentTypeFromFilePath("/tmp/index.html")).toBe("text/html; charset=utf-8"); + expect(getContentTypeFromFilePath("/tmp/app.js")).toBe("text/javascript; charset=utf-8"); + expect(getContentTypeFromFilePath("/tmp/logo.svg")).toBe("image/svg+xml"); + }); + + it("falls back to octet-stream for unknown extensions", () => { + expect(getContentTypeFromFilePath("/tmp/archive.custom")).toBe("application/octet-stream"); + }); }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 814abbb3..267a842e 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -1,4 +1,4 @@ -import Mime from "@effect/platform-node/Mime"; +import { getExtensionFromMimeType } from "./mime.ts"; export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/avif": ".avif", @@ -63,7 +63,7 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return fromMime; } - const fromMimeExtension = Mime.getExtension(input.mimeType); + const fromMimeExtension = getExtensionFromMimeType(input.mimeType); if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { return fromMimeExtension; } diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d715148d..3bd5209a 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -178,9 +178,12 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("composer.mode.plan"), "mod+shift+p"); assert.equal(defaultsByCommand.get("composer.mode.code"), "mod+shift+c"); assert.equal(defaultsByCommand.get("composer.mode.review"), "mod+shift+r"); + assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); assert.equal(defaultsByCommand.get("app.reload"), "f5"); assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); }), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 815dc391..ab3393a7 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -13,6 +13,7 @@ import { KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, + MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, @@ -44,6 +45,7 @@ import { Stream, } from "effect"; import * as Semaphore from "effect/Semaphore"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -69,6 +71,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+p", command: "composer.mode.plan", when: "!terminalFocus" }, { key: "mod+shift+c", command: "composer.mode.code", when: "!terminalFocus" }, { key: "mod+shift+r", command: "composer.mode.review", when: "!terminalFocus" }, + { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "f5", command: "app.reload" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, @@ -77,6 +80,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ key: `mod+${index + 1}`, command, })), + // These intentionally come after thread jumps so they win while the picker is open. + ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + when: "modelPickerOpen", + })), ]; function normalizeKeyToken(token: string): string { @@ -667,15 +676,18 @@ const makeKeybindings = Effect.gen(function* () { return { keybindings, issues }; }); - const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { - const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; - - return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( + const writeConfigAtomically = (rules: readonly KeybindingRule[]) => + Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + Effect.flatMap((encoded) => + writeFileStringAtomically({ + filePath: keybindingsConfigPath, + contents: encoded, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ), Effect.mapError( (cause) => new KeybindingsConfigError({ @@ -685,7 +697,6 @@ const makeKeybindings = Effect.gen(function* () { }), ), ); - }; const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( Effect.map(({ keybindings, issues }) => ({ diff --git a/apps/server/src/mime.ts b/apps/server/src/mime.ts new file mode 100644 index 00000000..a47c587e --- /dev/null +++ b/apps/server/src/mime.ts @@ -0,0 +1,54 @@ +const MIME_TYPE_BY_EXTENSION: Record = { + ".avif": "image/avif", + ".bmp": "image/bmp", + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".heic": "image/heic", + ".heif": "image/heif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { + "image/avif": ".avif", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/heic": ".heic", + "image/heif": ".heif", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/svg+xml": ".svg", + "image/tiff": ".tiff", + "image/webp": ".webp", +}; + +export function getContentTypeFromFilePath(filePath: string): string { + const normalizedPath = filePath.trim().toLowerCase(); + const extensionStart = normalizedPath.lastIndexOf("."); + if (extensionStart === -1) { + return "application/octet-stream"; + } + + const extension = normalizedPath.slice(extensionStart); + return MIME_TYPE_BY_EXTENSION[extension] ?? "application/octet-stream"; +} + +export function getExtensionFromMimeType(mimeType: string): string | undefined { + return IMAGE_EXTENSION_BY_MIME_TYPE[mimeType.trim().toLowerCase()]; +} diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index 59b0239c..382daab2 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -42,6 +42,16 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["/tmp/workspace"], }); + const kiroLaunch = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLaunch, { + command: "kiro", + args: ["ide", "/tmp/workspace"], + }); + const vscodeLaunch = yield* resolveEditorLaunch( { cwd: "/tmp/workspace", editor: "vscode" }, "darwin", @@ -122,6 +132,16 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], }); + const kiroLineAndColumn = yield* resolveEditorLaunch( + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" }, + "darwin", + { PATH: "" }, + ); + assert.deepEqual(kiroLineAndColumn, { + command: "kiro", + args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"], + }); + const vscodeLineAndColumn = yield* resolveEditorLaunch( { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, "darwin", @@ -354,6 +374,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); + yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); @@ -361,7 +382,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { PATH: dir, PATHEXT: ".COM;.EXE;.BAT;.CMD", }); - assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); + assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]); }), ); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index ef50d3a5..1d807158 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -11,7 +11,7 @@ import { accessSync, constants, statSync } from "node:fs"; import { extname, join } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; -import { ServiceMap, Effect, Layer } from "effect"; +import { ServiceMap, Effect, Layer, Schema } from "effect"; // ============================== // Definitions @@ -75,6 +75,14 @@ function resolveCommandEditorArgs( } } +function resolveEditorArgs( + editor: (typeof EDITORS)[number], + target: string, +): ReadonlyArray { + const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; + return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; +} + function resolveAvailableCommand( commands: ReadonlyArray, options: CommandAvailabilityOptions = {}, @@ -273,7 +281,7 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; return { command, - args: resolveCommandEditorArgs(editorDef, input.cwd), + args: resolveEditorArgs(editorDef, input.cwd), }; } @@ -293,11 +301,16 @@ export const launchDetached = (launch: EditorLaunch) => yield* Effect.callback((resume) => { let child; try { - child = spawn(launch.command, [...launch.args], { - detached: true, - stdio: "ignore", - shell: process.platform === "win32", - }); + const isWin32 = process.platform === "win32"; + child = spawn( + launch.command, + isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args], + { + detached: true, + stdio: "ignore", + shell: isWin32, + }, + ); } catch (error) { return resume( Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), @@ -316,20 +329,21 @@ export const launchDetached = (launch: EditorLaunch) => }); }); -const make = Effect.gen(function* () { - const open = yield* Effect.tryPromise({ - try: () => import("open"), - catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }), - }); - - return { - openBrowser: (target) => - Effect.tryPromise({ - try: () => open.default(target), - catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), - }), - openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), - } satisfies OpenShape; -}); +const make = Effect.succeed({ + openBrowser: (target) => + Effect.tryPromise({ + try: async () => { + const open = await import("open").catch((cause) => { + throw new OpenError({ message: "failed to load browser opener", cause }); + }); + return open.default(target); + }, + catch: (cause) => + Schema.is(OpenError)(cause) + ? cause + : new OpenError({ message: "Browser auto-open failed", cause }), + }), + openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached), +} satisfies OpenShape); export const OpenLive = Layer.effect(Open, make); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 18507454..617695ef 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1504,6 +1504,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("clears stale pending approvals from projected shell summaries", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-stale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-stale-approval"), + occurredAt: "2026-02-26T12:30:00.000Z", + commandId: CommandId.makeUnsafe("cmd-stale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-stale-approval"), + title: "Project Stale Approval", + workspaceRoot: "/tmp/project-stale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:30:00.000Z", + updatedAt: "2026-02-26T12:30:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-stale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:01.000Z", + commandId: CommandId.makeUnsafe("cmd-stale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-stale-approval"), + projectId: ProjectId.makeUnsafe("project-stale-approval"), + title: "Thread Stale Approval", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:30:01.000Z", + updatedAt: "2026-02-26T12:30:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-stale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:02.000Z", + commandId: CommandId.makeUnsafe("cmd-stale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-stale-approval"), + activity: { + id: EventId.makeUnsafe("activity-stale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-stale-1", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:30:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-stale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:03.000Z", + commandId: CommandId.makeUnsafe("cmd-stale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-stale-approval"), + activity: { + id: EventId.makeUnsafe("activity-stale-approval-failed"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-stale-1", + detail: "Unknown pending permission request: approval-request-stale-1", + }, + turnId: null, + createdAt: "2026-02-26T12:30:03.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-request-stale-1' + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-stale-1", + status: "resolved", + resolvedAt: "2026-02-26T12:30:03.000Z", + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-stale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]); + }), + ); + + it.effect("ignores non-stale provider approval response failures", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-nonstale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-nonstale-approval"), + occurredAt: "2026-02-26T12:45:00.000Z", + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-nonstale-approval"), + title: "Project Non-Stale Approval", + workspaceRoot: "/tmp/project-nonstale-approval", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:45:00.000Z", + updatedAt: "2026-02-26T12:45:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-nonstale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:01.000Z", + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), + projectId: ProjectId.makeUnsafe("project-nonstale-approval"), + title: "Thread Non-Stale Approval", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:45:01.000Z", + updatedAt: "2026-02-26T12:45:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-nonstale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.000Z", + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), + activity: { + id: EventId.makeUnsafe("activity-nonstale-approval-requested"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: { + requestId: "approval-request-nonstale-existing", + requestKind: "command", + }, + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-nonstale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:03.000Z", + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), + activity: { + id: EventId.makeUnsafe("activity-nonstale-approval-failed-existing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-existing", + detail: "Provider timed out while responding to approval request", + }, + turnId: TurnId.makeUnsafe("turn-nonstale-failure"), + createdAt: "2026-02-26T12:45:03.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.makeUnsafe("evt-nonstale-approval-5"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:04.000Z", + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-5"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-5"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), + activity: { + id: EventId.makeUnsafe("activity-nonstale-approval-failed-missing"), + tone: "error", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + payload: { + requestId: "approval-request-nonstale-missing", + detail: "Provider timed out while responding to approval request", + }, + turnId: null, + createdAt: "2026-02-26T12:45:04.000Z", + }, + }, + }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + readonly turnId: string | null; + readonly createdAt: string; + readonly resolvedAt: string | null; + }>` + SELECT + request_id AS "requestId", + status, + turn_id AS "turnId", + created_at AS "createdAt", + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id IN ( + 'approval-request-nonstale-existing', + 'approval-request-nonstale-missing' + ) + ORDER BY request_id + `; + assert.deepEqual(approvalRows, [ + { + requestId: "approval-request-nonstale-existing", + status: "pending", + turnId: null, + createdAt: "2026-02-26T12:45:02.000Z", + resolvedAt: null, + }, + ]); + + const threadRows = yield* sql<{ + readonly pendingApprovalCount: number; + }>` + SELECT pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + WHERE thread_id = 'thread-nonstale-approval' + `; + assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); + }), + ); + it.effect("does not fallback-retain messages whose turnId is removed by revert", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0844cf8b..5189a88a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -89,6 +89,17 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; } +function isStalePendingApprovalFailureDetail(detail: string | null): boolean { + if (detail === null) { + return false; + } + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") + ); +} + function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -1121,6 +1132,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; } + if (event.payload.activity.kind === "provider.approval.respond.failed") { + const payload = + typeof event.payload.activity.payload === "object" && + event.payload.activity.payload !== null + ? (event.payload.activity.payload as Record) + : null; + const detail = + typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (isStalePendingApprovalFailureDetail(detail)) { + if (Option.isNone(existingRow)) { + return; + } + if (existingRow.value.status === "resolved") { + return; + } + yield* projectionPendingApprovalRepository.upsert({ + requestId, + threadId: existingRow.value.threadId, + turnId: existingRow.value.turnId, + status: "resolved", + decision: null, + createdAt: existingRow.value.createdAt, + resolvedAt: event.payload.activity.createdAt, + }); + return; + } + return; + } + // Only approval-requested activities should create pending-approval + // rows. Other activity kinds that happen to carry a requestId + // (e.g. user-input.requested / user-input.resolved) must not + // pollute this projection — they have their own accounting via + // derivePendingUserInputCountFromActivities. + if (event.payload.activity.kind !== "approval.requested") { + return; + } if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { return; } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c6956..d9d27dba 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { normalizePersistedProviderInteractionMode } from "../../persistence/interactionMode.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -60,11 +61,20 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( - Struct.assign({ - modelSelection: Schema.fromJsonString(ModelSelection), - }), -); +const ProjectionThreadDbRowSchema = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + projectId: ProjectionThread.fields.projectId, + title: ProjectionThread.fields.title, + modelSelection: Schema.fromJsonString(ModelSelection), + runtimeMode: ProjectionThread.fields.runtimeMode, + interactionMode: Schema.String, + branch: ProjectionThread.fields.branch, + worktreePath: ProjectionThread.fields.worktreePath, + createdAt: ProjectionThread.fields.createdAt, + updatedAt: ProjectionThread.fields.updatedAt, + archivedAt: ProjectionThread.fields.archivedAt, + deletedAt: ProjectionThread.fields.deletedAt, +}); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -669,7 +679,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, + interactionMode: normalizePersistedProviderInteractionMode(row.interactionMode), branch: row.branch, worktreePath: row.worktreePath, latestTurn: latestTurnByThread.get(row.threadId) ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ab1dc3ed..94441d8e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -28,6 +28,10 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../../git/Services/GitStatusBroadcaster.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -190,6 +194,24 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); + const refreshStatus = vi.fn((_: string) => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "renamed-branch", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ); const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ @@ -237,6 +259,15 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge( + Layer.succeed(GitStatusBroadcaster, { + getStatus: () => Effect.die("getStatus should not be called in this test"), + refreshLocalStatus: () => + Effect.die("refreshLocalStatus should not be called in this test"), + refreshStatus, + streamStatus: () => Stream.die("streamStatus should not be called in this test"), + } satisfies GitStatusBroadcasterShape), + ), Layer.provideMerge( Layer.mock(TextGeneration, { generateBranchName, @@ -291,6 +322,7 @@ describe("ProviderCommandReactor", () => { respondToUserInput, stopSession, renameBranch, + refreshStatus, generateBranchName, generateThreadTitle, stateDir, @@ -525,9 +557,11 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.refreshStatus.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ message: "Add a safer reconnect backoff.", }); + expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); it("forwards codex model options through session start and turn send", async () => { @@ -1339,6 +1373,61 @@ describe("ProviderCommandReactor", () => { }); }); + it("starts a fresh session when only projected session state exists", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-session-set-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + session: { + threadId: ThreadId.makeUnsafe("thread-1"), + status: "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-stale"), + role: "user", + text: "resume codex", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + }); + }); + it("reacts to thread.approval.respond by forwarding provider approval response", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 78a237a4..3890cc49 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -18,6 +18,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; @@ -159,6 +160,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -262,7 +264,7 @@ const make = Effect.gen(function* () { detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, }); } - const preferredProvider: ProviderKind = currentProvider ?? threadProvider; + const preferredProvider: ProviderKind = threadProvider; const desiredModelSelection = requestedModelSelection ?? resolvedThreadModelSelection; const effectiveCwd = resolveThreadWorkspaceCwd({ thread, @@ -303,14 +305,11 @@ const make = Effect.gen(function* () { createdAt, }); + const activeSession = yield* resolveActiveSession(threadId); const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" ? thread.id : null; + thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; - const providerChanged = - requestedModelSelection !== undefined && - requestedModelSelection.provider !== currentProvider; - const activeSession = yield* resolveActiveSession(existingSessionThreadId); const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -327,17 +326,15 @@ const make = Effect.gen(function* () { if ( !runtimeModeChanged && - !providerChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { return existingSessionThreadId; } - const resumeCursor = - providerChanged || shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, @@ -346,7 +343,6 @@ const make = Effect.gen(function* () { currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, - providerChanged, modelChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, @@ -473,6 +469,7 @@ const make = Effect.gen(function* () { branch: renamed.branch, worktreePath: cwd, }); + yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index ca03ab58..89eba62d 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -6,7 +6,7 @@ describe("fixPath", () => { it("hydrates PATH on linux using the resolved login shell", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/usr/bin", + PATH: "/Users/test/.local/bin:/usr/bin", }; const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); @@ -17,6 +17,39 @@ describe("fixPath", () => { }); expect(readPath).toHaveBeenCalledWith("/bin/zsh"); + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("falls back to launchctl PATH on macOS when shell probing fails", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/opt/homebrew/bin/nu", + PATH: "/usr/bin", + }; + const readPath = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("unknown flag"); + }) + .mockImplementationOnce(() => undefined); + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); + const logWarning = vi.fn(); + + fixPath({ + env, + platform: "darwin", + readPath, + readLaunchctlPath, + userShell: "/bin/zsh", + logWarning, + }); + + expect(readPath).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu"); + expect(readPath).toHaveBeenNthCalledWith(2, "/bin/zsh"); + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); + expect(logWarning).toHaveBeenCalledWith( + "Failed to read PATH from login shell /opt/homebrew/bin/nu.", + expect.any(Error), + ); expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index c3629e8f..5ea849cc 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,40 +1,63 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; +import { + listLoginShellCandidates, + mergePathEntries, + readPathFromLaunchctl, + readPathFromLoginShell, +} from "@t3tools/shared/shell"; +import { expandHomePath as expandHomePathValue } from "./pathExpansion"; + +function logPathHydrationWarning(message: string, error?: unknown): void { + console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? "")); +} export function fixPath( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readLaunchctlPath?: typeof readPathFromLaunchctl; + userShell?: string; + logWarning?: (message: string, error?: unknown) => void; } = {}, ): void { const platform = options.platform ?? process.platform; if (platform !== "darwin" && platform !== "linux") return; const env = options.env ?? process.env; + const logWarning = options.logWarning ?? logPathHydrationWarning; + const readPath = options.readPath ?? readPathFromLoginShell; try { - const shell = resolveLoginShell(platform, env.SHELL); - if (!shell) return; - const result = (options.readPath ?? readPathFromLoginShell)(shell); - if (result) { - env.PATH = result; + let shellPath: string | undefined; + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { + try { + shellPath = readPath(shell); + } catch (error) { + logWarning(`Failed to read PATH from login shell ${shell}.`, error); + } + + if (shellPath) { + break; + } } - } catch { - // Silently ignore — keep default PATH + + const launchctlPath = + platform === "darwin" && !shellPath + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; + } + } catch (error) { + logWarning("Failed to hydrate PATH from the user environment.", error); } } export const expandHomePath = Effect.fn(function* (input: string) { - const { join } = yield* Path.Path; - if (input === "~") { - return OS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return join(OS.homedir(), input.slice(2)); - } - return input; + return expandHomePathValue(input); }); export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { diff --git a/apps/server/src/pathExpansion.test.ts b/apps/server/src/pathExpansion.test.ts new file mode 100644 index 00000000..40ca25e6 --- /dev/null +++ b/apps/server/src/pathExpansion.test.ts @@ -0,0 +1,33 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { expandHomePath } from "./pathExpansion.ts"; + +describe("expandHomePath", () => { + it("returns an empty string unchanged", () => { + expect(expandHomePath("")).toBe(""); + }); + + it("returns paths without a leading tilde unchanged", () => { + expect(expandHomePath("/absolute/path")).toBe("/absolute/path"); + expect(expandHomePath("relative/path")).toBe("relative/path"); + expect(expandHomePath("some~weird~path")).toBe("some~weird~path"); + }); + + it("expands a lone tilde to the home directory", () => { + expect(expandHomePath("~")).toBe(homedir()); + }); + + it("expands ~/ to a subpath of the home directory", () => { + expect(expandHomePath("~/.codex-work")).toBe(join(homedir(), ".codex-work")); + }); + + it("expands a Windows-style ~\\ prefix", () => { + expect(expandHomePath("~\\.codex")).toBe(join(homedir(), ".codex")); + }); + + it("does not expand ~user paths", () => { + expect(expandHomePath("~alice/foo")).toBe("~alice/foo"); + }); +}); diff --git a/apps/server/src/pathExpansion.ts b/apps/server/src/pathExpansion.ts new file mode 100644 index 00000000..18060c3e --- /dev/null +++ b/apps/server/src/pathExpansion.ts @@ -0,0 +1,23 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Expand a leading `~` (or `~/…`, `~\…`) in a user-supplied path to the + * current user's home directory. Spawned processes don't get shell + * expansion, so env vars like `CODEX_HOME=~/.codex-work` would be passed + * verbatim and treated as relative paths by the receiver. + * + * Matches the behavior of the other `expandHomePath` helpers in the + * workspace layers and CLI bootstrap: `~` alone and both `~/` and `~\` + * separators are handled. Returns the input unchanged if it doesn't + * start with `~` or is empty. Does not handle `~user` (other-user) + * expansion. + */ +export function expandHomePath(value: string): string { + if (!value) return value; + if (value === "~") return homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return join(homedir(), value.slice(2)); + } + return value; +} diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 48dd51fd..c5e17343 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -12,13 +12,43 @@ import { type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; import { ModelSelection } from "@t3tools/contracts"; +import { normalizePersistedProviderInteractionMode } from "../interactionMode.ts"; -const ProjectionThreadDbRow = ProjectionThread.mapFields( - Struct.assign({ - modelSelection: Schema.fromJsonString(ModelSelection), - }), -); +const ProjectionThreadDbRow = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + projectId: ProjectionThread.fields.projectId, + title: ProjectionThread.fields.title, + modelSelection: Schema.fromJsonString(ModelSelection), + runtimeMode: ProjectionThread.fields.runtimeMode, + interactionMode: Schema.String, + branch: ProjectionThread.fields.branch, + worktreePath: ProjectionThread.fields.worktreePath, + latestTurnId: ProjectionThread.fields.latestTurnId, + createdAt: ProjectionThread.fields.createdAt, + updatedAt: ProjectionThread.fields.updatedAt, + archivedAt: ProjectionThread.fields.archivedAt, + deletedAt: ProjectionThread.fields.deletedAt, +}); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; +type ProjectionThreadRecord = typeof ProjectionThread.Type; + +function normalizeProjectionThreadRow(row: ProjectionThreadDbRow): ProjectionThreadRecord { + return { + threadId: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: normalizePersistedProviderInteractionMode(row.interactionMode), + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + }; +} const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -139,11 +169,13 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( + Effect.map(Option.map((row) => normalizeProjectionThreadRow(row))), Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( + Effect.map((rows) => rows.map((row) => normalizeProjectionThreadRow(row))), Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8c9fe4d9..023e3bca 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,6 +35,9 @@ import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; +import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts"; +import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts"; +import Migration0025 from "./Migrations/025_CleanupInvalidProjectionPendingApprovals.ts"; /** * Migration loader with all migrations defined inline. @@ -69,6 +72,9 @@ export const migrationEntries = [ [20, "AuthAccessManagement", Migration0020], [21, "AuthSessionClientMetadata", Migration0021], [22, "AuthSessionLastConnectedAt", Migration0022], + [23, "ProjectionThreadShellSummary", Migration0023], + [24, "BackfillProjectionThreadShellSummary", Migration0024], + [25, "CleanupInvalidProjectionPendingApprovals", Migration0025], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts new file mode 100644 index 00000000..d0ede09b --- /dev/null +++ b/apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts @@ -0,0 +1,38 @@ +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) + `; + const columnNames = new Set(columns.map((column) => column.name)); + + if (!columnNames.has("latest_user_message_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN latest_user_message_at TEXT + `; + } + + if (!columnNames.has("pending_approval_count")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_approval_count INTEGER NOT NULL DEFAULT 0 + `; + } + + if (!columnNames.has("pending_user_input_count")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pending_user_input_count INTEGER NOT NULL DEFAULT 0 + `; + } + + if (!columnNames.has("has_actionable_proposed_plan")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0 + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts new file mode 100644 index 00000000..cc911d24 --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts @@ -0,0 +1,218 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("024_BackfillProjectionThreadShellSummary", (it) => { + it.effect("backfills thread shell summary fields and clears stale projected approvals", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 23 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-1', + 'project-1', + 'Thread 1', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'plan', + NULL, + NULL, + 'turn-1', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'message-user-1', + 'thread-1', + 'turn-1', + 'user', + 'Need help', + NULL, + 0, + '2026-02-24T00:01:00.000Z', + '2026-02-24T00:01:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-1', + 'turn-1', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-1","requestKind":"command"}', + NULL, + '2026-02-24T00:02:00.000Z' + ), + ( + 'activity-approval-stale', + 'thread-1', + 'turn-1', + 'error', + 'provider.approval.respond.failed', + 'Provider approval response failed', + '{"requestId":"approval-1","detail":"Unknown pending permission request: approval-1"}', + NULL, + '2026-02-24T00:03:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-1', + 'turn-1', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-1","questions":[{"id":"area","header":"Area","question":"Which repo area should I inspect next?","options":[{"label":"Server","description":"Server orchestration."}]}]}', + NULL, + '2026-02-24T00:04:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, + thread_id, + turn_id, + plan_markdown, + implemented_at, + implementation_thread_id, + created_at, + updated_at + ) + VALUES ( + 'plan-1', + 'thread-1', + 'turn-1', + '# Do the thing', + NULL, + NULL, + '2026-02-24T00:05:00.000Z', + '2026-02-24T00:05:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES ( + 'approval-1', + 'thread-1', + 'turn-1', + 'pending', + NULL, + '2026-02-24T00:02:00.000Z', + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + const threadRows = yield* sql<{ + readonly latestUserMessageAt: string | null; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly hasActionableProposedPlan: number; + }>` + SELECT + latest_user_message_at AS "latestUserMessageAt", + pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount", + has_actionable_proposed_plan AS "hasActionableProposedPlan" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepStrictEqual(threadRows, [ + { + latestUserMessageAt: "2026-02-24T00:01:00.000Z", + pendingApprovalCount: 0, + pendingUserInputCount: 1, + hasActionableProposedPlan: 1, + }, + ]); + + const approvalRows = yield* sql<{ + readonly status: string; + readonly resolvedAt: string | null; + }>` + SELECT + status, + resolved_at AS "resolvedAt" + FROM projection_pending_approvals + WHERE request_id = 'approval-1' + `; + assert.deepStrictEqual(approvalRows, [ + { + status: "resolved", + resolvedAt: "2026-02-24T00:03:00.000Z", + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts new file mode 100644 index 00000000..549906df --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts @@ -0,0 +1,277 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT OR IGNORE INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + SELECT + requested.request_id, + requested.thread_id, + requested.turn_id, + 'pending', + NULL, + requested.created_at, + NULL + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + thread_id, + turn_id, + created_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at ASC, activity_id ASC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS requested + WHERE requested.row_number = 1 + `; + + yield* sql` + WITH latest_resolutions AS ( + SELECT + resolved.request_id, + resolved.resolved_at, + resolved.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'approval.resolved' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS resolved + WHERE resolved.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_resolutions.decision + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_resolutions.resolved_at + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_resolutions + WHERE latest_resolutions.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_response_events AS ( + SELECT + response.request_id, + response.resolved_at, + response.decision + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + occurred_at AS resolved_at, + CASE + WHEN json_extract(payload_json, '$.decision') IN ( + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ) + THEN json_extract(payload_json, '$.decision') + ELSE NULL + END AS decision, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY occurred_at DESC, sequence DESC + ) AS row_number + FROM orchestration_events + WHERE event_type = 'thread.approval-response-requested' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + ) AS response + WHERE response.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = ( + SELECT latest_response_events.decision + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ), + resolved_at = ( + SELECT latest_response_events.resolved_at + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + WHERE EXISTS ( + SELECT 1 + FROM latest_response_events + WHERE latest_response_events.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + WITH latest_stale_failures AS ( + SELECT + failure.request_id, + failure.resolved_at + FROM ( + SELECT + json_extract(payload_json, '$.requestId') AS request_id, + created_at AS resolved_at, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(payload_json, '$.requestId') + ORDER BY created_at DESC, activity_id DESC + ) AS row_number + FROM projection_thread_activities + WHERE kind = 'provider.approval.respond.failed' + AND json_extract(payload_json, '$.requestId') IS NOT NULL + AND ( + lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%stale pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending approval request%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%unknown pending permission request%' + ) + ) AS failure + WHERE failure.row_number = 1 + ) + UPDATE projection_pending_approvals + SET + status = 'resolved', + decision = NULL, + resolved_at = ( + SELECT latest_stale_failures.resolved_at + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + WHERE status = 'pending' + AND EXISTS ( + SELECT 1 + FROM latest_stale_failures + WHERE latest_stale_failures.request_id = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET + latest_user_message_at = ( + SELECT MAX(message.created_at) + FROM projection_thread_messages AS message + WHERE message.thread_id = projection_threads.thread_id + AND message.role = 'user' + ), + pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0), + pending_user_input_count = COALESCE(( + WITH latest_user_input_states AS ( + SELECT + latest.request_id, + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = projection_threads.thread_id + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT COUNT(*) + FROM latest_user_input_states + WHERE latest_user_input_states.kind = 'user-input.requested' + OR ( + latest_user_input_states.kind = 'provider.user-input.respond.failed' + AND latest_user_input_states.detail NOT LIKE '%stale pending user-input request%' + AND latest_user_input_states.detail NOT LIKE '%unknown pending user-input request%' + ) + ), 0), + has_actionable_proposed_plan = COALESCE(( + SELECT CASE + WHEN projection_threads.latest_turn_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS latest_turn_plan_exists + WHERE latest_turn_plan_exists.thread_id = projection_threads.thread_id + AND latest_turn_plan_exists.turn_id = projection_threads.latest_turn_id + ) + THEN CASE + WHEN ( + SELECT latest_turn_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_turn_plan + WHERE latest_turn_plan.thread_id = projection_threads.thread_id + AND latest_turn_plan.turn_id = projection_threads.latest_turn_id + ORDER BY latest_turn_plan.updated_at DESC, latest_turn_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + WHEN EXISTS ( + SELECT 1 + FROM projection_thread_proposed_plans AS any_plan + WHERE any_plan.thread_id = projection_threads.thread_id + ) + THEN CASE + WHEN ( + SELECT latest_plan.implemented_at + FROM projection_thread_proposed_plans AS latest_plan + WHERE latest_plan.thread_id = projection_threads.thread_id + ORDER BY latest_plan.updated_at DESC, latest_plan.plan_id DESC + LIMIT 1 + ) IS NULL + THEN 1 + ELSE 0 + END + ELSE 0 + END + ), 0) + `; +}); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts new file mode 100644 index 00000000..060cd471 --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -0,0 +1,196 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("025_CleanupInvalidProjectionPendingApprovals", (it) => { + it.effect("removes pending-approval rows that do not come from approval requests", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 24 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES + ( + 'thread-valid', + 'project-1', + 'Valid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-valid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 2, + 0, + 0, + NULL + ), + ( + 'thread-invalid', + 'project-1', + 'Invalid thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + 'turn-invalid', + '2026-04-13T00:00:00.000Z', + '2026-04-13T00:00:00.000Z', + NULL, + NULL, + 1, + 0, + 0, + NULL + ) + `; + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES + ( + 'activity-approval-requested', + 'thread-valid', + 'turn-valid', + 'approval', + 'approval.requested', + 'Command approval requested', + '{"requestId":"approval-valid","requestKind":"command"}', + NULL, + '2026-04-13T00:01:00.000Z' + ), + ( + 'activity-user-input-requested', + 'thread-invalid', + 'turn-invalid', + 'info', + 'user-input.requested', + 'User input requested', + '{"requestId":"input-invalid","questions":[{"id":"scope","header":"Scope","question":"What should I inspect?","options":[{"label":"Server","description":"Inspect server code."}]}]}', + NULL, + '2026-04-13T00:02:00.000Z' + ) + `; + + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, + thread_id, + turn_id, + status, + decision, + created_at, + resolved_at + ) + VALUES + ( + 'approval-valid', + 'thread-valid', + 'turn-valid', + 'pending', + NULL, + '2026-04-13T00:01:00.000Z', + NULL + ), + ( + 'input-invalid', + 'thread-invalid', + 'turn-invalid', + 'pending', + NULL, + '2026-04-13T00:02:00.000Z', + NULL + ), + ( + 'input-invalid-resolved', + 'thread-valid', + 'turn-valid', + 'resolved', + NULL, + '2026-04-13T00:03:00.000Z', + '2026-04-13T00:04:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 25 }); + + const approvalRows = yield* sql<{ + readonly requestId: string; + readonly status: string; + }>` + SELECT + request_id AS "requestId", + status + FROM projection_pending_approvals + ORDER BY request_id ASC + `; + assert.deepStrictEqual(approvalRows, [ + { + requestId: "approval-valid", + status: "pending", + }, + ]); + + const threadCounts = yield* sql<{ + readonly threadId: string; + readonly pendingApprovalCount: number; + }>` + SELECT + thread_id AS "threadId", + pending_approval_count AS "pendingApprovalCount" + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepStrictEqual(threadCounts, [ + { + threadId: "thread-invalid", + pendingApprovalCount: 0, + }, + { + threadId: "thread-valid", + pendingApprovalCount: 1, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts new file mode 100644 index 00000000..33a6512c --- /dev/null +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts @@ -0,0 +1,27 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DELETE FROM projection_pending_approvals + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_thread_activities AS activity + WHERE activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') + = projection_pending_approvals.request_id + ) + `; + + yield* sql` + UPDATE projection_threads + SET pending_approval_count = COALESCE(( + SELECT COUNT(*) + FROM projection_pending_approvals + WHERE projection_pending_approvals.thread_id = projection_threads.thread_id + AND projection_pending_approvals.status = 'pending' + ), 0) + `; +}); diff --git a/apps/server/src/persistence/interactionMode.ts b/apps/server/src/persistence/interactionMode.ts new file mode 100644 index 00000000..0c5c1218 --- /dev/null +++ b/apps/server/src/persistence/interactionMode.ts @@ -0,0 +1,24 @@ +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type ProviderInteractionMode, +} from "@t3tools/contracts"; + +const VALID_INTERACTION_MODES = new Set([ + "default", + "plan", + "ask", + "code", + "review", +]); + +export function normalizePersistedProviderInteractionMode( + value: string | null | undefined, +): ProviderInteractionMode { + if (!value || value === "swarm") { + return DEFAULT_PROVIDER_INTERACTION_MODE; + } + + return VALID_INTERACTION_MODES.has(value as ProviderInteractionMode) + ? (value as ProviderInteractionMode) + : DEFAULT_PROVIDER_INTERACTION_MODE; +} diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index dd909116..195f526b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { runProcess } from "./processRunner"; +import { isWindowsCommandNotFound, runProcess } from "./processRunner"; describe("runProcess", () => { it("fails when output exceeds max buffer in default mode", async () => { @@ -21,3 +21,21 @@ describe("runProcess", () => { expect(result.stderrTruncated).toBe(false); }); }); + +describe("isWindowsCommandNotFound", () => { + it("matches the localized German cmd.exe error text", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + try { + expect( + isWindowsCommandNotFound( + 1, + "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", + ), + ).toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); +}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 54026128..03b164fc 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,10 +37,23 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } +const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ + /is not recognized as an internal or external command/i, + /n.o . reconhecido como um comando interno/i, + /non . riconosciuto come comando interno o esterno/i, + /n.est pas reconnu en tant que commande interne/i, + /no se reconoce como un comando interno o externo/i, + /wird nicht als interner oder externer befehl/i, +] as const; + +function hasWindowsCommandNotFoundMessage(output: string): boolean { + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); +} + 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); + return hasWindowsCommandNotFoundMessage(stderr); } function normalizeExitError( diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts index 82f29ec3..bb9e38ab 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.test.ts @@ -59,6 +59,19 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("resolves IntelliJ project icons", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, ".idea/icon.svg", "idea"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain(".idea/icon.svg"); + }), + ); + it.effect("resolves icon hrefs from project source files", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver; diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index 50d9a120..4d3517fd 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -28,6 +28,7 @@ const FAVICON_CANDIDATES = [ "assets/logo.svg", "assets/logo.png", "assets/prod/logo.svg", + ".idea/icon.svg", ] as const; // Files that may contain a or icon metadata declaration. diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts index 741d000f..5835393e 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -154,7 +154,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ); it.effect( - "refreshes cached null identities after the negative TTL when a remote is configured later", + "keeps null identities cached across repeated resolves until the negative TTL expires", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -170,8 +170,10 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); - const cachedIdentity = yield* resolver.resolve(cwd); - expect(cachedIdentity).toBeNull(); + for (const _attempt of [1, 2, 3]) { + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + } yield* TestClock.adjust(Duration.millis(120)); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 130c2250..8ebb6088 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -70,7 +70,7 @@ function buildRepositoryIdentity(input: { const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); -const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1); interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5a09d8b6..fd5e799d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1407,6 +1407,150 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("clamps oversized Claude usage to the reported context window", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-clamped", + usage: { + total_tokens: 535000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(usageEvent.payload, { + usage: { + usedTokens: 200000, + lastUsedTokens: 200000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "preserves oversized Claude result totals after task progress snapshots are recorded", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-clamped", + description: "Thinking through the patch", + usage: { + total_tokens: 190000, + }, + session_id: "sdk-session-task-usage-clamped", + uuid: "task-usage-progress-clamped", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-clamped-after-progress", + usage: { + total_tokens: 535000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const finalUsageEvent = usageEvents.at(-1); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload, { + usage: { + usedTokens: 190000, + lastUsedTokens: 190000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect( "emits completion only after turn result when assistant frames arrive before deltas", () => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97d56998..a4adde9f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,6 +17,7 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, + ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, @@ -274,24 +275,14 @@ function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.makeUnsafe(value); } -function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | undefined { - if (!modelUsage || typeof modelUsage !== "object") { - return undefined; - } +function maxClaudeContextWindowFromModelUsage( + modelUsage: Record | undefined, +): number | undefined { + if (!modelUsage) return undefined; let maxContextWindow: number | undefined; - for (const value of Object.values(modelUsage as Record)) { - if (!value || typeof value !== "object") { - continue; - } - const contextWindow = (value as { contextWindow?: unknown }).contextWindow; - if ( - typeof contextWindow !== "number" || - !Number.isFinite(contextWindow) || - contextWindow <= 0 - ) { - continue; - } + for (const value of Object.values(modelUsage)) { + const contextWindow = value.contextWindow; maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); } @@ -299,53 +290,58 @@ function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | und } function normalizeClaudeTokenUsage( - usage: unknown, + value: unknown, contextWindow?: number, ): ThreadTokenUsageSnapshot | undefined { - if (!usage || typeof usage !== "object") { + if (!value || typeof value !== "object") { return undefined; } - const record = usage as Record; - const directUsedTokens = - typeof record.total_tokens === "number" && Number.isFinite(record.total_tokens) - ? record.total_tokens - : undefined; + const usage = value as Record; const inputTokens = - (typeof record.input_tokens === "number" && Number.isFinite(record.input_tokens) - ? record.input_tokens + (typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) + ? usage.input_tokens : 0) + - (typeof record.cache_creation_input_tokens === "number" && - Number.isFinite(record.cache_creation_input_tokens) - ? record.cache_creation_input_tokens + (typeof usage.cache_creation_input_tokens === "number" && + Number.isFinite(usage.cache_creation_input_tokens) + ? usage.cache_creation_input_tokens : 0) + - (typeof record.cache_read_input_tokens === "number" && - Number.isFinite(record.cache_read_input_tokens) - ? record.cache_read_input_tokens + (typeof usage.cache_read_input_tokens === "number" && + Number.isFinite(usage.cache_read_input_tokens) + ? usage.cache_read_input_tokens : 0); const outputTokens = - typeof record.output_tokens === "number" && Number.isFinite(record.output_tokens) - ? record.output_tokens + typeof usage.output_tokens === "number" && Number.isFinite(usage.output_tokens) + ? usage.output_tokens : 0; - const derivedUsedTokens = inputTokens + outputTokens; - const usedTokens = directUsedTokens ?? (derivedUsedTokens > 0 ? derivedUsedTokens : undefined); - if (usedTokens === undefined || usedTokens <= 0) { + const derivedTotalProcessedTokens = inputTokens + outputTokens; + const totalProcessedTokens = + (typeof usage.total_tokens === "number" && Number.isFinite(usage.total_tokens) + ? usage.total_tokens + : undefined) ?? (derivedTotalProcessedTokens > 0 ? derivedTotalProcessedTokens : undefined); + if (totalProcessedTokens === undefined || totalProcessedTokens <= 0) { return undefined; } + const maxTokens = + typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined; + const usedTokens = + maxTokens !== undefined ? Math.min(totalProcessedTokens, maxTokens) : totalProcessedTokens; + return { usedTokens, lastUsedTokens: usedTokens, + ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), ...(inputTokens > 0 ? { inputTokens } : {}), ...(outputTokens > 0 ? { outputTokens } : {}), - ...(typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 - ? { maxTokens: contextWindow } - : {}), - ...(typeof record.tool_uses === "number" && Number.isFinite(record.tool_uses) - ? { toolUses: record.tool_uses } + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(typeof usage.tool_uses === "number" && Number.isFinite(usage.tool_uses) + ? { toolUses: usage.tool_uses } : {}), - ...(typeof record.duration_ms === "number" && Number.isFinite(record.duration_ms) - ? { durationMs: record.duration_ms } + ...(typeof usage.duration_ms === "number" && Number.isFinite(usage.duration_ms) + ? { durationMs: usage.duration_ms } : {}), }; } @@ -1330,8 +1326,6 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( errorMessage?: string, result?: SDKResultMessage, ) { - const resultUsage = - result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); if (resultContextWindow !== undefined) { context.lastKnownContextWindow = resultContextWindow; @@ -1343,9 +1337,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Instead, use the last known context-window-accurate usage from task_progress // events and treat the accumulated total as totalProcessedTokens. const accumulatedSnapshot = normalizeClaudeTokenUsage( - resultUsage, + result?.usage, resultContextWindow ?? context.lastKnownContextWindow, ); + const accumulatedTotalProcessedTokens = + accumulatedSnapshot?.totalProcessedTokens ?? accumulatedSnapshot?.usedTokens; const lastGoodUsage = context.lastKnownTokenUsage; const maxTokens = resultContextWindow ?? context.lastKnownContextWindow; const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage @@ -1354,8 +1350,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 ? { maxTokens } : {}), - ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens - ? { totalProcessedTokens: accumulatedSnapshot.usedTokens } + ...(typeof accumulatedTotalProcessedTokens === "number" && + Number.isFinite(accumulatedTotalProcessedTokens) && + accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { totalProcessedTokens: accumulatedTotalProcessedTokens } : {}), } : accumulatedSnapshot; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d95a4d76..b62d21e3 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -9,9 +9,10 @@ import type { import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; +import { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, DEFAULT_TIMEOUT_MS, detailFromResult, @@ -276,18 +277,6 @@ function extractClaudeAuthMethodFromOutput(result: CommandResult): string | unde return Option.getOrUndefined(findAuthMethod(parsed.success)); } -// ── Dynamic model capability adjustment ───────────────────────────── - -/** Subscription types where the 1M context window is included in the plan. */ -const PREMIUM_SUBSCRIPTION_TYPES = new Set([ - "max", - "maxplan", - "max5", - "max20", - "enterprise", - "team", -]); - function toTitleCaseWords(value: string): string { return value .split(/[\s_-]+/g) @@ -348,52 +337,27 @@ function claudeAuthMetadata(input: { return undefined; } -/** - * Adjust the built-in model list based on the user's detected subscription. - * - * - Premium tiers (Max, Enterprise, Team): 1M context becomes the default. - * - Other tiers (Pro, free, unknown): 200k context stays the default; - * 1M remains available as a manual option so users can still enable it. - */ -export function adjustModelsForSubscription( - baseModels: ReadonlyArray, - subscriptionType: string | undefined, -): ReadonlyArray { - const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); - if (!normalized || !PREMIUM_SUBSCRIPTION_TYPES.has(normalized)) { - return baseModels; - } - - // Flip 1M to be the default for premium users - return baseModels.map((model) => { - const caps = model.capabilities; - if (!caps || caps.contextWindowOptions.length === 0) return model; - - return { - ...model, - capabilities: { - ...caps, - contextWindowOptions: caps.contextWindowOptions.map((opt) => - opt.value === "1m" - ? { value: opt.value, label: opt.label, isDefault: true as const } - : { value: opt.value, label: opt.label }, - ), - }, - }; - }); -} - // ── SDK capability probe ──────────────────────────────────────────── const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; +function waitForAbortSignal(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. * - * The prompt is never sent to the Anthropic API — we abort immediately - * after the local initialization phase completes. This gives us the - * user's subscription type without incurring any token cost. + * We pass a never-yielding AsyncIterable as the prompt so that no user + * message is ever written to the subprocess stdin. This means the Claude + * Code subprocess completes its local initialization IPC (returning + * account info and slash commands) but never starts an API request to + * Anthropic. We read the init data and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include * subscription type information. @@ -402,13 +366,17 @@ const probeClaudeCapabilities = (binaryPath: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { const q = claudeQuery({ - prompt: ".", + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield + prompt: (async function* (): AsyncGenerator { + await waitForAbortSignal(abort.signal); + })(), options: { persistSession: false, pathToClaudeCodeExecutable: binaryPath, abortController: abort, - maxTurns: 0, - settingSources: [], + settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, }, @@ -541,7 +509,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // ── Auth check + subscription detection ──────────────────────────── const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), Effect.result, ); @@ -563,8 +531,6 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); } - const resolvedModels = adjustModelsForSubscription(models, subscriptionType); - // ── Handle auth results (same logic as before, adjusted models) ── if (Result.isFailure(authProbe)) { @@ -573,7 +539,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, probe: { installed: true, version: parsedVersion, @@ -592,7 +558,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, probe: { installed: true, version: parsedVersion, @@ -609,7 +575,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, probe: { installed: true, version: parsedVersion, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index db91e8da..f9feba00 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -141,10 +141,10 @@ class FakeCodexManager extends CodexAppServerManager { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + remove: () => Effect.void, getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), - remove: () => Effect.void, listThreadIds: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 74319d58..834c6ef8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -47,6 +47,7 @@ import { import { probeCodexAccount } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsService } from "../../serverSettings"; +import { expandHomePath } from "../../pathExpansion"; import { ServerSettingsError } from "@t3tools/contracts"; const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { @@ -268,6 +269,7 @@ export const readCodexConfigModelProvider = Effect.fn("readCodexConfigModelProvi process.env.CODEX_HOME || path.join(OS.homedir(), ".codex"), ), + Effect.map(expandHomePath), ); const configPath = path.join(codexHome, "config.toml"); @@ -323,7 +325,7 @@ const runCodexCommand = Effect.fn("runCodexCommand")(function* (args: ReadonlyAr shell: process.platform === "win32", env: { ...process.env, - ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + ...(codexSettings.homePath ? { CODEX_HOME: expandHomePath(codexSettings.homePath) } : {}), }, }); return yield* spawnAndCollect(codexSettings.binaryPath, command); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6a844220..43af723d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -203,7 +203,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "pro"); - assert.strictEqual(status.auth.label, "ChatGPT Pro Subscription"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 20x Subscription"); assert.deepStrictEqual( status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), true, @@ -220,6 +220,38 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("labels chatgpt prolite accounts and keeps spark disabled", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + type: "chatgpt" as const, + planType: "prolite" as const, + sparkEnabled: false, + }), + ); + + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "prolite"); + assert.strictEqual(status.auth.label, "ChatGPT Pro 5x Subscription"); + assert.deepStrictEqual( + status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), + false, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides spark from codex models for unsupported chatgpt plans", () => Effect.gen(function* () { yield* withTempCodexHome(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2e7dde68..55d961ba 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -582,6 +582,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } + // An explicit user stop must be terminal. Leaving a persisted binding + // behind would let the next routed action auto-recover the session, + // which violates the user's intent to stop this thread until they + // deliberately start a new provider session. yield* directory.remove(input.threadId); yield* analytics.record("provider.session.stopped", { provider: routed.adapter.provider, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index aefe31b6..a66a9c0f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; -import { assertFailure, assertSome } from "@effect/vitest/utils"; +import { assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -15,7 +15,6 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; -import { ProviderSessionDirectoryPersistenceError } from "../Errors.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -31,7 +30,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts, reads, and removes thread bindings", () => + it("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntimeRepository; @@ -76,16 +75,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const threadIds = yield* directory.listThreadIds(); assert.deepEqual(threadIds, [nextThreadId]); - - yield* directory.remove(nextThreadId); - const missingProvider = yield* directory.getProvider(nextThreadId).pipe(Effect.result); - assertFailure( - missingProvider, - new ProviderSessionDirectoryPersistenceError({ - operation: "ProviderSessionDirectory.getProvider", - detail: `No persisted provider binding found for thread '${nextThreadId}'.`, - }), - ); })); it("persists runtime fields and merges payload updates", () => @@ -133,6 +122,30 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("removes persisted bindings when a thread is explicitly forgotten", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + const threadId = ThreadId.makeUnsafe("thread-remove"); + + yield* directory.upsert({ + provider: "codex", + threadId, + status: "running", + }); + + yield* directory.remove(threadId); + + const binding = yield* directory.getBinding(threadId); + assert.equal(Option.isNone(binding), true); + + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isNone(runtime), true); + + const threadIds = yield* directory.listThreadIds(); + assert.deepEqual(threadIds, []); + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d6..bff95c08 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -116,6 +116,13 @@ const makeProviderSessionDirectory = Effect.gen(function* () { .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); + const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => + repository + .deleteByThreadId({ threadId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), + ); + const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) => getBinding(threadId).pipe( Effect.flatMap((binding) => @@ -132,13 +139,6 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const remove: ProviderSessionDirectoryShape["remove"] = (threadId) => - repository - .deleteByThreadId({ threadId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteByThreadId")), - ); - const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -147,9 +147,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + remove, getProvider, getBinding, - remove, listThreadIds, } satisfies ProviderSessionDirectoryShape; }); diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 3a374976..16e2863d 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -33,6 +33,14 @@ export interface ProviderSessionDirectoryShape { binding: ProviderRuntimeBinding, ) => Effect.Effect; + /** + * Remove a persisted binding entirely. + * + * Use this for explicit user stops, where the caller expects the thread to + * stop being resumable until a brand new session is started. + */ + readonly remove: (threadId: ThreadId) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; @@ -41,10 +49,6 @@ export interface ProviderSessionDirectoryShape { threadId: ThreadId, ) => Effect.Effect, ProviderSessionDirectoryReadError>; - readonly remove: ( - threadId: ThreadId, - ) => Effect.Effect; - readonly listThreadIds: () => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/codexAccount.ts b/apps/server/src/provider/codexAccount.ts index 1db00250..f55f0886 100644 --- a/apps/server/src/provider/codexAccount.ts +++ b/apps/server/src/provider/codexAccount.ts @@ -5,6 +5,7 @@ export type CodexPlanType = | "go" | "plus" | "pro" + | "prolite" | "team" | "business" | "enterprise" @@ -86,7 +87,9 @@ export function codexAuthSubLabel(account: CodexAccountSnapshot | undefined): st case "plus": return "ChatGPT Plus Subscription"; case "pro": - return "ChatGPT Pro Subscription"; + return "ChatGPT Pro 20x Subscription"; + case "prolite": + return "ChatGPT Pro 5x Subscription"; case "team": return "ChatGPT Team Subscription"; case "business": diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index b704125f..47d3a8fe 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -1,6 +1,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import readline from "node:readline"; import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; +import { expandHomePath } from "../pathExpansion"; interface JsonRpcProbeResponse { readonly id?: unknown; @@ -132,7 +133,7 @@ export async function probeCodexUsage(input: { const child = spawn(input.binaryPath, ["app-server"], { env: { ...process.env, - ...(input.homePath ? { CODEX_HOME: input.homePath } : {}), + ...(input.homePath ? { CODEX_HOME: expandHomePath(input.homePath) } : {}), }, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4c80d78e..24228ca1 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -11,6 +11,7 @@ import { normalizeModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner"; export const DEFAULT_TIMEOUT_MS = 4_000; +export const AUTH_PROBE_TIMEOUT_MS = 10_000; export interface CommandResult { readonly stdout: string; diff --git a/apps/server/src/provider/usage/modules/codexUsageModule.ts b/apps/server/src/provider/usage/modules/codexUsageModule.ts index 68567d4d..b451803b 100644 --- a/apps/server/src/provider/usage/modules/codexUsageModule.ts +++ b/apps/server/src/provider/usage/modules/codexUsageModule.ts @@ -5,6 +5,7 @@ import { Effect, Result } from "effect"; import { type CodexAccountSnapshot, codexAuthSubLabel } from "../../codexAccount"; import { parseAuthStatusFromOutput } from "../../Layers/CodexProvider"; import { isCommandMissingCause } from "../../providerSnapshot"; +import { expandHomePath } from "../../../pathExpansion"; import { ServerSettingsService } from "../../../serverSettings"; import { fetchWithFallback, @@ -136,7 +137,9 @@ export const makeCodexUsageModule = Effect.gen(function* () { command: providerSettings.binaryPath, args: ["login", "status"], timeoutMs: 4_000, - ...(providerSettings.homePath ? { env: { CODEX_HOME: providerSettings.homePath } } : {}), + ...(providerSettings.homePath + ? { env: { CODEX_HOME: expandHomePath(providerSettings.homePath) } } + : {}), }), ), Effect.map((result) => { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 410868b9..51c0e75d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1851,6 +1851,58 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc projects.searchEntries excludes gitignored files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-project-search-gitignored-", + }); + yield* fs.writeFileString(path.join(workspaceDir, ".gitignore"), ".venv/\n"); + yield* fs.makeDirectory(path.join(workspaceDir, ".venv", "lib"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, ".venv", "lib", "ignored-search-target.ts"), + "export const ignored = true;", + ); + yield* fs.makeDirectory(path.join(workspaceDir, "src"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "src", "tracked.ts"), + "export const ok = 1;", + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + isInsideWorkTree: () => Effect.succeed(true), + listWorkspaceFiles: () => + Effect.succeed({ + paths: ["src/tracked.ts"], + truncated: false, + }), + filterIgnoredPaths: (_cwd, relativePaths) => + Effect.succeed( + relativePaths.filter((relativePath) => !relativePath.startsWith(".venv/")), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsSearchEntries]({ + cwd: workspaceDir, + query: "ignored-search-target", + limit: 10, + }), + ), + ); + + assert.equal(response.entries.length, 0); + assert.equal(response.truncated, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries errors", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1955,6 +2007,34 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc filesystem.browse", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-filesystem-browse-", + }); + yield* fs.makeDirectory(path.join(workspaceDir, "project-alpha"), { recursive: true }); + yield* fs.makeDirectory(path.join(workspaceDir, "project-beta"), { recursive: true }); + + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.filesystemBrowse]({ + partialPath: `${workspaceDir}/project-`, + }), + ), + ); + + assert.deepEqual(response.entries, [ + { name: "project-alpha", fullPath: path.join(workspaceDir, "project-alpha") }, + { name: "project-beta", fullPath: path.join(workspaceDir, "project-beta") }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc shell.openInEditor errors", () => Effect.gen(function* () { const openError = new OpenError({ message: "Editor command not found: cursor" }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index de8792f6..c35725db 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -76,7 +76,7 @@ import { ServerAuthLive } from "./auth/Layers/ServerAuth"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { - if (typeof Bun !== "undefined") { + if (typeof Bun !== "undefined" && process.platform !== "win32") { const BunPTY = yield* Effect.promise(() => import("./terminal/Layers/BunPTY")); return BunPTY.layer; } else { @@ -215,13 +215,20 @@ const GitLayerLive = Layer.empty.pipe( const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(GitCoreLive), +); + +const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), + WorkspaceEntriesLayerLive, + WorkspaceFileSystemLayerLive, ); const AuthLayerLive = ServerAuthLive.pipe( diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 6a637f87..342ee8dc 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -43,6 +43,7 @@ import { Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -750,15 +751,15 @@ const makeServerSettings = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); const writeSettingsAtomically = (settings: ServerSettings) => { - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_NORMALIZED_SERVER_SETTINGS) ?? {}; - return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( - Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, settingsPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + return writeFileStringAtomically({ + filePath: settingsPath, + contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new ServerSettingsError({ diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69b..c91201c0 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -73,6 +73,38 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => { vi.restoreAllMocks(); }); + describe("browse", () => { + it.effect("lists matching child directories for a browsed path prefix", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-prefix-" }); + yield* writeTextFile(cwd, "project-a/README.md"); + yield* writeTextFile(cwd, "project-b/README.md"); + yield* writeTextFile(cwd, "notes.txt"); + + const workspaceEntries = yield* WorkspaceEntries; + const result = yield* workspaceEntries.browse({ + partialPath: `${cwd}/pro`, + }); + + expect(result.entries).toEqual([ + { name: "project-a", fullPath: `${cwd}/project-a` }, + { name: "project-b", fullPath: `${cwd}/project-b` }, + ]); + }), + ); + + it.effect("supports browsing from the home-directory shorthand", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries; + const result = yield* workspaceEntries.browse({ + partialPath: "~/", + }); + + expect(result.parentPath.length).toBeGreaterThan(0); + }), + ); + }); + describe("search", () => { it.effect("returns files and directories relative to cwd", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 12af8601..af2e9727 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -1,13 +1,15 @@ import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; +import * as OS from "node:os"; import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; -import { type ProjectEntry } from "@t3tools/contracts"; +import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries, + WorkspaceEntriesBrowseError, WorkspaceEntriesError, type WorkspaceEntriesShape, } from "../Services/WorkspaceEntries.ts"; @@ -217,6 +219,69 @@ function directoryAncestorsOf(relativePath: string): string[] { const processErrorDetail = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +function isWindowsAbsolutePath(value: string): boolean { + return isUncPath(value) || isWindowsDrivePath(value); +} + +function isExplicitRelativePath(value: string): boolean { + return ( + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + +const resolveBrowseTarget = ( + input: FilesystemBrowseInput, + pathService: Path.Path, +): Effect.Effect => + Effect.gen(function* () { + if (process.platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Windows-style paths are only supported on Windows.", + }); + } + + if (!isExplicitRelativePath(input.partialPath)) { + return pathService.resolve(expandHomePath(input.partialPath, pathService)); + } + + if (!input.cwd) { + return yield* new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.resolveBrowseTarget", + detail: "Relative filesystem browse paths require a current project.", + }); + } + + return pathService.resolve(expandHomePath(input.cwd, pathService), input.partialPath); + }); + export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; const gitOption = yield* Effect.serviceOption(GitCore); @@ -465,6 +530,46 @@ export const makeWorkspaceEntries = Effect.gen(function* () { }, ); + const browse: WorkspaceEntriesShape["browse"] = Effect.fn("WorkspaceEntries.browse")( + function* (input) { + const resolvedInputPath = yield* resolveBrowseTarget(input, path); + const endsWithSeparator = /[\\/]$/.test(input.partialPath) || input.partialPath === "~"; + const parentPath = endsWithSeparator ? resolvedInputPath : path.dirname(resolvedInputPath); + const prefix = endsWithSeparator ? "" : path.basename(resolvedInputPath); + + const dirents = yield* Effect.tryPromise({ + try: () => fsPromises.readdir(parentPath, { withFileTypes: true }), + catch: (cause) => + new WorkspaceEntriesBrowseError({ + cwd: input.cwd, + partialPath: input.partialPath, + operation: "workspaceEntries.browse.readDirectory", + detail: `Unable to browse '${parentPath}': ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }); + + const showHidden = endsWithSeparator || prefix.startsWith("."); + const lowerPrefix = prefix.toLowerCase(); + + return { + parentPath, + entries: dirents + .filter( + (dirent) => + dirent.isDirectory() && + dirent.name.toLowerCase().startsWith(lowerPrefix) && + (showHidden || !dirent.name.startsWith(".")), + ) + .map((dirent) => ({ + name: dirent.name, + fullPath: path.join(parentPath, dirent.name), + })) + .toSorted((left, right) => left.name.localeCompare(right.name)), + }; + }, + ); + const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); @@ -495,6 +600,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ); return { + browse, invalidate, search, } satisfies WorkspaceEntriesShape; diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index fa7a90cf..686ac35a 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -1,5 +1,5 @@ -import * as OS from "node:os"; import { Effect, FileSystem, Layer, Path } from "effect"; +import { expandHomePath } from "../../pathExpansion"; import { WorkspacePaths, @@ -13,16 +13,6 @@ function toPosixRelativePath(input: string): string { return input.replaceAll("\\", "/"); } -function expandHomePath(input: string, path: Path.Path): string { - if (input === "~") { - return OS.homedir(); - } - if (input.startsWith("~/") || input.startsWith("~\\")) { - return path.join(OS.homedir(), input.slice(2)); - } - return input; -} - export const makeWorkspacePaths = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -30,7 +20,7 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePathsShape["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim())); const workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) .pipe(Effect.catch(() => Effect.succeed(null))); diff --git a/apps/server/src/workspace/Services/WorkspaceEntries.ts b/apps/server/src/workspace/Services/WorkspaceEntries.ts index 2841b1fe..c6b0d7af 100644 --- a/apps/server/src/workspace/Services/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Services/WorkspaceEntries.ts @@ -9,7 +9,12 @@ import { Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + ProjectSearchEntriesInput, + ProjectSearchEntriesResult, +} from "@t3tools/contracts"; export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( "WorkspaceEntriesError", @@ -21,11 +26,29 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesBrowseError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + /** * WorkspaceEntriesShape - Service API for workspace entry search and cache * invalidation. */ export interface WorkspaceEntriesShape { + /** + * Browse matching directories for the provided partial path. + */ + readonly browse: ( + input: FilesystemBrowseInput, + ) => Effect.Effect; + /** * Search indexed workspace entries for files and directories matching the * provided query. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 25a65f72..54b1e532 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,6 +3,7 @@ import { type AuthSessionId, CommandId, EventId, + FilesystemBrowseError, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, @@ -360,6 +361,7 @@ const makeWsRpcLayer = () => branch: worktree.worktree.branch, worktreePath: targetWorktreePath, }); + yield* refreshGitStatus(targetWorktreePath); } yield* runSetupProgram(); @@ -684,6 +686,20 @@ const makeWsRpcLayer = () => observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", }), + [WS_METHODS.filesystemBrowse]: (input) => + observeRpcEffect( + WS_METHODS.filesystemBrowse, + workspaceEntries.browse(input).pipe( + Effect.mapError( + (cause) => + new FilesystemBrowseError({ + message: cause.detail, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeGitStatus]: (input) => observeRpcStream( WS_METHODS.subscribeGitStatus, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 033a8a1d..9020086a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -3,6 +3,10 @@ import { useNavigate } from "@tanstack/react-router"; import ThreadSidebar from "./Sidebar"; import { Sidebar, SidebarRail } from "./ui/sidebar"; +import { + clearShortcutModifierState, + syncShortcutModifierStateFromKeyboardEvent, +} from "../shortcutModifierState"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; @@ -11,6 +15,28 @@ const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowKeyUp = (event: KeyboardEvent) => { + syncShortcutModifierStateFromKeyboardEvent(event); + }; + const onWindowBlur = () => { + clearShortcutModifierState(); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + window.addEventListener("keyup", onWindowKeyUp, true); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + window.removeEventListener("keyup", onWindowKeyUp, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 184fb415..1ebcf7fc 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1653,6 +1653,55 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows Kiro in the open picker menu and opens the project cwd with it", async () => { + setDraftThreadWithoutWorktree(); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + availableEditors: ["kiro"], + }; + }, + }); + + try { + await waitForServerConfigToApply(); + const menuButton = await waitForElement( + () => document.querySelector('button[aria-label="Copy options"]'), + "Unable to find Open picker button.", + ); + (menuButton as HTMLButtonElement).click(); + + const kiroItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find((item) => + item.textContent?.includes("Kiro"), + ) ?? null, + "Unable to find Kiro menu item.", + ); + (kiroItem as HTMLElement).click(); + + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.shellOpenInEditor, + ); + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/repo/project", + editor: "kiro", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("filters the open picker menu and opens VSCodium from the menu", async () => { setDraftThreadWithoutWorktree(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c03cdd6..74d14545 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -765,6 +765,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const [providerUsagePanelOpen, setProviderUsagePanelOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); + const [composerModelPickerOpen, setComposerModelPickerOpen] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -3266,12 +3267,17 @@ export default function ChatView({ threadId }: ChatViewProps) { terminalOpenByThreadRef.current[activeThreadId] = current; }, [activeThreadId, focusComposer, terminalState.terminalOpen]); + useEffect(() => { + setComposerModelPickerOpen(false); + }, [activeThreadId, lockedProvider, selectedProvider]); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), + modelPickerOpen: composerModelPickerOpen, }; const command = resolveShortcutCommand(event, keybindings, { @@ -3321,6 +3327,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "modelPicker.toggle") { + event.preventDefault(); + event.stopPropagation(); + setComposerModelPickerOpen((current) => !current); + return; + } + if ( command === "composer.mode.ask" || command === "composer.mode.plan" || @@ -3349,10 +3362,11 @@ export default function ChatView({ threadId }: ChatViewProps) { event.stopPropagation(); void runProjectScript(script); }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, + composerModelPickerOpen, terminalState.terminalOpen, terminalState.activeTerminalId, activeThreadId, @@ -5341,8 +5355,12 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} providers={providerStatuses} + keybindings={keybindings} modelOptionsByProvider={modelOptionsByProvider} showAsAuto={isExplicitAutoModelSelection || isModelFromModeSettings} + terminalOpen={Boolean(terminalState.terminalOpen)} + open={composerModelPickerOpen} + onOpenChange={setComposerModelPickerOpen} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2e95b54e..9b97d23a 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -34,6 +34,24 @@ export const TraeIcon: Icon = (props) => ( ); +export const KiroIcon: Icon = (props) => ( + + + + + + +); + export const VisualStudioCode: Icon = (props) => { const id = useId(); const maskId = `${id}-vscode-a`; diff --git a/apps/web/src/components/ProjectFolderBrowser.tsx b/apps/web/src/components/ProjectFolderBrowser.tsx new file mode 100644 index 00000000..7946d88c --- /dev/null +++ b/apps/web/src/components/ProjectFolderBrowser.tsx @@ -0,0 +1,152 @@ +import { FolderIcon } from "lucide-react"; +import type { FilesystemBrowseEntry } from "@t3tools/contracts"; + +import { cn } from "../lib/utils"; +import { canNavigateUp, ensureBrowseDirectoryPath } from "../lib/projectPaths"; + +type ProjectFolderBrowserVariant = "sidebar" | "fullscreen"; + +export function ProjectFolderBrowser({ + variant, + browsePath, + browseEntries, + browseCurrentDirectory, + browseError, + isBrowsingFilesystem, + isAddingProject, + onBrowsePathChange, + onBrowse, + onBrowseUp, + onBrowseEntryOpen, + onAddCurrentDirectory, +}: { + variant: ProjectFolderBrowserVariant; + browsePath: string; + browseEntries: ReadonlyArray; + browseCurrentDirectory: string | null; + browseError: string | null; + isBrowsingFilesystem: boolean; + isAddingProject: boolean; + onBrowsePathChange: (nextPath: string) => void; + onBrowse: () => void; + onBrowseUp: () => void; + onBrowseEntryOpen: (entry: FilesystemBrowseEntry) => void; + onAddCurrentDirectory: () => void; +}) { + const isFullscreen = variant === "fullscreen"; + const canBrowseUp = + browseCurrentDirectory !== null && + canNavigateUp(ensureBrowseDirectoryPath(browseCurrentDirectory)) && + !isBrowsingFilesystem; + + return ( +
+
+ + onBrowsePathChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onBrowse(); + } + }} + /> + +
+ + {browseCurrentDirectory ? ( + + ) : null} + +
+ {browseEntries.length === 0 ? ( +

+ {isBrowsingFilesystem ? "Loading folders..." : "No matching folders in this location."} +

+ ) : ( + browseEntries.map((entry) => ( + + )) + )} +
+ + {browseError ? ( +

+ {browseError} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/ProjectFolderPickerDialog.tsx b/apps/web/src/components/ProjectFolderPickerDialog.tsx new file mode 100644 index 00000000..a1a6cf75 --- /dev/null +++ b/apps/web/src/components/ProjectFolderPickerDialog.tsx @@ -0,0 +1,112 @@ +import type { FilesystemBrowseEntry } from "@t3tools/contracts"; + +import { Dialog, DialogDescription, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; +import { ProjectFolderBrowser } from "./ProjectFolderBrowser"; + +export function ProjectFolderPickerDialog({ + open, + newCwd, + addProjectError, + canAddProject, + isAddingProject, + browsePath, + browseEntries, + browseCurrentDirectory, + browseError, + isBrowsingFilesystem, + onOpenChange, + onNewCwdChange, + onAddProject, + onBrowsePathChange, + onBrowse, + onBrowseUp, + onBrowseEntryOpen, + onAddCurrentDirectory, +}: { + open: boolean; + newCwd: string; + addProjectError: string | null; + canAddProject: boolean; + isAddingProject: boolean; + browsePath: string; + browseEntries: ReadonlyArray; + browseCurrentDirectory: string | null; + browseError: string | null; + isBrowsingFilesystem: boolean; + onOpenChange: (open: boolean) => void; + onNewCwdChange: (nextPath: string) => void; + onAddProject: () => void; + onBrowsePathChange: (nextPath: string) => void; + onBrowse: () => void; + onBrowseUp: () => void; + onBrowseEntryOpen: (entry: FilesystemBrowseEntry) => void; + onAddCurrentDirectory: () => void; +}) { + return ( + + + + Add Project + + Browse to a folder or paste a path directly. The same filesystem browser powers both + picker layouts so appearance settings only change presentation, not behavior. + + + +
+
+
+ Project path +
+
+ onNewCwdChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onAddProject(); + } + }} + autoFocus + /> + +
+ {addProjectError ? ( +

{addProjectError}

+ ) : null} +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f55d97d3..90b2ff82 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,6 +45,7 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type FilesystemBrowseEntry, ProjectId, ThreadId, type GitStatusResult, @@ -57,7 +58,14 @@ import { import { isElectron } from "../env"; import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { cn, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { + ensureBrowseDirectoryPath, + findProjectByPath, + getBrowseParentPath, + inferProjectTitleFromPath, + normalizeProjectPathForDispatch, +} from "../lib/projectPaths"; import { useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -108,7 +116,6 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getProjectThreadsForSidebar, getVisibleSidebarThreadIds, @@ -127,6 +134,8 @@ import { sortThreadsForSidebar, useThreadJumpHintVisibility, } from "./Sidebar.logic"; +import { ProjectFolderBrowser } from "./ProjectFolderBrowser"; +import { ProjectFolderPickerDialog } from "./ProjectFolderPickerDialog"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -728,9 +737,15 @@ export default function Sidebar() { const keybindings = useServerKeybindings(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); + const [browsePanelOpen, setBrowsePanelOpen] = useState(false); + const [browsePath, setBrowsePath] = useState(""); + const [browseEntries, setBrowseEntries] = useState>([]); + const [browseCurrentDirectory, setBrowseCurrentDirectory] = useState(null); + const [browseError, setBrowseError] = useState(null); + const [isBrowsingFilesystem, setIsBrowsingFilesystem] = useState(false); + const [projectPickerDialogOpen, setProjectPickerDialogOpen] = useState(false); const addProjectInputRef = useRef(null); const [renamingProjectId, setRenamingProjectId] = useState(null); const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); @@ -756,13 +771,13 @@ export default function Sidebar() { const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const isMacDesktop = isElectron && isMacPlatform(navigator.platform); const shouldShowSidebarWordmark = true; const shouldShowSidebarLogo = true; const platform = navigator.platform; - const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const usesFullscreenProjectPicker = isElectron && appSettings.projectPickerMode === "fullscreen"; + const shouldShowProjectPathEntry = addingProject && !usesFullscreenProjectPicker; + const isProjectPickerOpen = addingProject || projectPickerDialogOpen; const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -854,7 +869,7 @@ export default function Sidebar() { const addProjectFromPath = useCallback( async (rawCwd: string) => { - const cwd = rawCwd.trim(); + const cwd = normalizeProjectPathForDispatch(rawCwd); if (!cwd || isAddingProject) return; const api = readNativeApi(); if (!api) return; @@ -862,12 +877,21 @@ export default function Sidebar() { setIsAddingProject(true); const finishAddingProject = () => { setIsAddingProject(false); + // Keep the inline and fullscreen pickers on the same reset path so changing the + // appearance setting never leaves stale browse state waiting in the other shell. setNewCwd(""); setAddProjectError(null); + setBrowsePanelOpen(false); + setBrowsePath(""); + setBrowseEntries([]); + setBrowseCurrentDirectory(null); + setBrowseError(null); + setIsBrowsingFilesystem(false); setAddingProject(false); + setProjectPickerDialogOpen(false); }; - const existing = projects.find((project) => project.cwd === cwd); + const existing = findProjectByPath(projects, cwd); if (existing) { focusMostRecentThreadForProject(existing.id); finishAddingProject(); @@ -876,7 +900,7 @@ export default function Sidebar() { const projectId = newProjectId(); const createdAt = new Date().toISOString(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; + const title = inferProjectTitleFromPath(cwd); try { await api.orchestration.dispatchCommand({ type: "project.create", @@ -897,15 +921,7 @@ export default function Sidebar() { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: "Failed to add project", - description, - }); - } else { - setAddProjectError(description); - } + setAddProjectError(description); return; } finishAddingProject(); @@ -915,7 +931,6 @@ export default function Sidebar() { handleNewThread, isAddingProject, projects, - shouldBrowseForProjectImmediately, appSettings.defaultThreadEnvMode, ], ); @@ -926,31 +941,94 @@ export default function Sidebar() { const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - const handlePickFolder = async () => { - const api = readNativeApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. + const browseFilesystem = useCallback( + async (requestedPath: string) => { + const api = readNativeApi(); + const partialPath = requestedPath.trim(); + if (!api || !partialPath || isBrowsingFilesystem) return; + + setIsBrowsingFilesystem(true); + setBrowseError(null); + try { + const result = await api.filesystem.browse({ partialPath }); + const browsingDirectory = /[\\/]$/.test(partialPath) || partialPath === "~"; + const resolvedDirectory = browsingDirectory + ? normalizeProjectPathForDispatch(result.parentPath) + : null; + setBrowsePath(partialPath); + setBrowseEntries(result.entries); + setBrowseCurrentDirectory(resolvedDirectory); + if (resolvedDirectory) { + setNewCwd(resolvedDirectory); + } + } catch (error) { + setBrowseEntries([]); + setBrowseCurrentDirectory(null); + setBrowseError(error instanceof Error ? error.message : "Unable to browse folders."); + } finally { + setIsBrowsingFilesystem(false); + } + }, + [isBrowsingFilesystem], + ); + + const openBrowsePanel = useCallback(() => { + const initialPath = + newCwd.trim().length > 0 + ? ensureBrowseDirectoryPath(normalizeProjectPathForDispatch(newCwd)) + : "~/"; + setBrowsePanelOpen(true); + setBrowsePath(initialPath); + void browseFilesystem(initialPath); + }, [browseFilesystem, newCwd]); + + const closeProjectPicker = useCallback(() => { + setAddingProject(false); + setProjectPickerDialogOpen(false); + setIsAddingProject(false); + setIsBrowsingFilesystem(false); + setNewCwd(""); + setAddProjectError(null); + setBrowsePanelOpen(false); + setBrowsePath(""); + setBrowseEntries([]); + setBrowseCurrentDirectory(null); + setBrowseError(null); + }, []); + + const handleBrowseUp = useCallback(() => { + if (!browseCurrentDirectory) { + return; } - if (pickedPath) { - await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { - addProjectInputRef.current?.focus(); + const parentPath = getBrowseParentPath(ensureBrowseDirectoryPath(browseCurrentDirectory)); + if (!parentPath) { + return; } - setIsPickingFolder(false); - }; + void browseFilesystem(parentPath); + }, [browseCurrentDirectory, browseFilesystem]); + + const handleBrowseEntryOpen = useCallback( + (entry: FilesystemBrowseEntry) => { + void browseFilesystem(ensureBrowseDirectoryPath(entry.fullPath)); + }, + [browseFilesystem], + ); const handleStartAddProject = () => { + if (isProjectPickerOpen) { + closeProjectPicker(); + return; + } setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); + if (usesFullscreenProjectPicker) { + setProjectPickerDialogOpen(true); + openBrowsePanel(); return; } - setAddingProject((prev) => !prev); + setAddingProject(true); + if (isElectron) { + openBrowsePanel(); + } }; const cancelRename = useCallback(() => { @@ -2251,10 +2329,8 @@ export default function Sidebar() { render={ - )}
{ if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); + closeProjectPicker(); } }} autoFocus @@ -2317,6 +2381,46 @@ export default function Sidebar() { {isAddingProject ? "Adding..." : "Add"}
+
+ +
+ {browsePanelOpen && ( + { + setBrowsePath(nextPath); + setBrowseError(null); + }} + onBrowse={() => void browseFilesystem(browsePath)} + onBrowseUp={handleBrowseUp} + onBrowseEntryOpen={handleBrowseEntryOpen} + onAddCurrentDirectory={() => { + if (browseCurrentDirectory) { + void addProjectFromPath(browseCurrentDirectory); + } + }} + /> + )} {addProjectError && (

{addProjectError} @@ -2360,7 +2464,7 @@ export default function Sidebar() { )} - {projects.length === 0 && !shouldShowProjectPathEntry && ( + {projects.length === 0 && !isProjectPickerOpen && (

No projects yet
@@ -2384,6 +2488,43 @@ export default function Sidebar() { + + { + if (open) { + setProjectPickerDialogOpen(true); + return; + } + closeProjectPicker(); + }} + onNewCwdChange={(nextPath) => { + setNewCwd(nextPath); + setAddProjectError(null); + }} + onAddProject={handleAddProject} + onBrowsePathChange={(nextPath) => { + setBrowsePath(nextPath); + setBrowseError(null); + }} + onBrowse={() => void browseFilesystem(browsePath)} + onBrowseUp={handleBrowseUp} + onBrowseEntryOpen={handleBrowseEntryOpen} + onAddCurrentDirectory={() => { + if (browseCurrentDirectory) { + void addProjectFromPath(browseCurrentDirectory); + } + }} + /> )} diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx new file mode 100644 index 00000000..0bbb391c --- /dev/null +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -0,0 +1,107 @@ +import { type ProviderKind } from "@t3tools/contracts"; +import { memo } from "react"; +import { StarIcon } from "lucide-react"; + +import { + getDisplayModelName, + getProviderLabel, + getTriggerDisplayModelLabel, + type ModelEsque, + PROVIDER_ICON_BY_PROVIDER, + PROVIDER_TINT_CLASS_BY_PROVIDER, +} from "./providerIconUtils"; +import { ComboboxItem } from "../ui/combobox"; +import { Kbd } from "../ui/kbd"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; + +export const ModelListRow = memo(function ModelListRow(props: { + index: number; + model: ModelEsque; + provider: ProviderKind; + isFavorite: boolean; + showProvider: boolean; + preferShortName?: boolean; + useTriggerLabel?: boolean; + showNewBadge?: boolean; + jumpLabel?: string | null; + onToggleFavorite: () => void; +}) { + const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; + + return ( + +
+ + { + event.stopPropagation(); + props.onToggleFavorite(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} + > + + + } + /> + + {props.isFavorite ? "Remove from favorites" : "Add to favorites"} + + + +
+
+
+ + {props.useTriggerLabel + ? getTriggerDisplayModelLabel(props.model) + : getDisplayModelName( + props.model, + props.preferShortName ? { preferShortName: true } : undefined, + )} + + {props.showNewBadge ? ( + + New + + ) : null} +
+ {props.jumpLabel ? ( + {props.jumpLabel} + ) : null} +
+ {props.showProvider ? ( +
+ + + {getProviderLabel(props.provider, props.model)} + +
+ ) : null} +
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx new file mode 100644 index 00000000..991f1c95 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -0,0 +1,528 @@ +import { + type ProviderKind, + PROVIDER_DISPLAY_NAMES, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { resolveSelectableModel } from "@t3tools/shared/model"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { SearchIcon } from "lucide-react"; + +import { ModelListRow } from "./ModelListRow"; +import { ModelPickerSidebar } from "./ModelPickerSidebar"; +import { isModelPickerNewModel } from "./modelPickerModelHighlights"; +import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; +import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; +import { type ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { + modelPickerJumpCommandForIndex, + modelPickerJumpIndexFromCommand, + resolveShortcutCommand, + shortcutLabelForCommand, +} from "../../keybindings"; +import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; +import { cn } from "~/lib/utils"; +import { TooltipProvider } from "../ui/tooltip"; + +type ModelPickerItem = ModelEsque & { + provider: ProviderKind; +}; + +const EMPTY_MODEL_JUMP_LABELS = new Map(); + +export const COMPOSER_AUTO_MODEL_VALUE = "auto"; + +export const ModelPickerContent = memo(function ModelPickerContent(props: { + provider: ProviderKind; + model: string; + lockedProvider: ProviderKind | null; + providers?: ReadonlyArray; + keybindings?: ResolvedKeybindingsConfig; + modelOptionsByProvider: Record>; + showAsAuto?: boolean; + terminalOpen?: boolean; + onRequestClose?: () => void; + onProviderModelChange: (provider: ProviderKind, model: string) => void; +}) { + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const listRegionRef = useRef(null); + const highlightedModelKeyRef = useRef(null); + const settings = useSettings(); + const favorites = settings.favorites; + const { updateSettings } = useUpdateSettings(); + const [selectedProvider, setSelectedProvider] = useState(() => { + if (props.lockedProvider !== null) { + return props.lockedProvider; + } + return favorites.length > 0 ? "favorites" : props.provider; + }); + + const keybindings = useMemo( + () => props.keybindings ?? [], + [props.keybindings], + ); + + const focusSearchInput = useCallback(() => { + searchInputRef.current?.focus({ preventScroll: true }); + }, []); + + const handleSelectProvider = useCallback( + (provider: ProviderKind | "favorites") => { + setSelectedProvider(provider); + window.requestAnimationFrame(() => { + focusSearchInput(); + }); + }, + [focusSearchInput], + ); + + useLayoutEffect(() => { + focusSearchInput(); + const frame = window.requestAnimationFrame(() => { + focusSearchInput(); + }); + const timeout = window.setTimeout(() => { + focusSearchInput(); + }, 0); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timeout); + }; + }, [focusSearchInput, selectedProvider]); + + const favoriteOrder = useMemo( + () => + new Map( + favorites.map((favorite, index) => [`${favorite.provider}:${favorite.model}`, index]), + ), + [favorites], + ); + const favoritesSet = useMemo>( + () => new Set(favorites.map((favorite) => `${favorite.provider}:${favorite.model}`)), + [favorites], + ); + const readyProviderSet = useMemo(() => { + if (!props.providers || props.providers.length === 0) { + return null; + } + return new Set( + props.providers + .filter((provider) => provider.status === "ready") + .map((provider) => provider.provider), + ); + }, [props.providers]); + const autoModelByProvider = useMemo( + () => + ({ + codex: { slug: COMPOSER_AUTO_MODEL_VALUE, name: "Auto" }, + claudeAgent: { slug: COMPOSER_AUTO_MODEL_VALUE, name: "Auto" }, + }) satisfies Record, + [], + ); + + const flatModels = useMemo(() => { + return Object.entries(props.modelOptionsByProvider).flatMap(([providerKind, models]) => { + if (readyProviderSet && !readyProviderSet.has(providerKind as ProviderKind)) { + return []; + } + + return [autoModelByProvider[providerKind as ProviderKind], ...models].map((model) => + Object.assign({}, model, { + provider: providerKind as ProviderKind, + }), + ) satisfies Array; + }); + }, [autoModelByProvider, props.modelOptionsByProvider, readyProviderSet]); + + const filteredModels = useMemo(() => { + let result = flatModels; + + if (searchQuery.trim()) { + const rankedMatches = result + .map((model) => ({ + model, + score: scoreModelPickerSearch( + { + ...model, + isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + }, + searchQuery, + ), + isFavorite: favoritesSet.has(`${model.provider}:${model.slug}`), + tieBreaker: buildModelPickerSearchText(model), + })) + .filter( + ( + rankedModel, + ): rankedModel is { + model: ModelPickerItem; + score: number; + isFavorite: boolean; + tieBreaker: string; + } => rankedModel.score !== null, + ); + + if (props.lockedProvider !== null) { + return rankedMatches + .filter((rankedModel) => rankedModel.model.provider === props.lockedProvider) + .toSorted((a, b) => { + const scoreDelta = a.score - b.score; + if (scoreDelta !== 0) { + return scoreDelta; + } + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } + return a.tieBreaker.localeCompare(b.tieBreaker); + }) + .map((rankedModel) => rankedModel.model); + } + + return rankedMatches + .toSorted((a, b) => { + const scoreDelta = a.score - b.score; + if (scoreDelta !== 0) { + return scoreDelta; + } + if (a.isFavorite !== b.isFavorite) { + return a.isFavorite ? -1 : 1; + } + return a.tieBreaker.localeCompare(b.tieBreaker); + }) + .map((rankedModel) => rankedModel.model); + } + + if (props.lockedProvider !== null) { + result = result.filter((model) => model.provider === props.lockedProvider); + } else if (selectedProvider === "favorites") { + result = result.filter((model) => favoritesSet.has(`${model.provider}:${model.slug}`)); + } else { + result = result.filter((model) => model.provider === selectedProvider); + } + + return result.toSorted((a, b) => { + const aOrder = favoriteOrder.get(`${a.provider}:${a.slug}`); + const bOrder = favoriteOrder.get(`${b.provider}:${b.slug}`); + + if (aOrder !== undefined && bOrder !== undefined) { + return aOrder - bOrder; + } + if (aOrder !== undefined) { + return -1; + } + if (bOrder !== undefined) { + return 1; + } + return a.name.localeCompare(b.name); + }); + }, [ + favoriteOrder, + favoritesSet, + flatModels, + props.lockedProvider, + searchQuery, + selectedProvider, + ]); + + const allModelKeys = useMemo( + (): string[] => flatModels.map((model) => `${model.provider}:${model.slug}`), + [flatModels], + ); + const filteredModelKeys = useMemo( + (): string[] => filteredModels.map((model) => `${model.provider}:${model.slug}`), + [filteredModels], + ); + const filteredModelByKey = useMemo>( + () => + new Map(filteredModels.map((model) => [`${model.provider}:${model.slug}`, model] as const)), + [filteredModels], + ); + + const handleModelSelect = useCallback( + (modelSlug: string, provider: ProviderKind) => { + if (modelSlug === COMPOSER_AUTO_MODEL_VALUE) { + props.onProviderModelChange(provider, COMPOSER_AUTO_MODEL_VALUE); + return; + } + const resolvedModel = resolveSelectableModel( + provider, + modelSlug, + props.modelOptionsByProvider[provider], + ); + if (resolvedModel) { + props.onProviderModelChange(provider, resolvedModel); + } + }, + [props], + ); + + const toggleFavorite = useCallback( + (provider: ProviderKind, model: string) => { + const nextFavorites = [...favorites]; + const existingIndex = nextFavorites.findIndex( + (favorite) => favorite.provider === provider && favorite.model === model, + ); + if (existingIndex >= 0) { + nextFavorites.splice(existingIndex, 1); + } else { + nextFavorites.push({ provider, model }); + } + updateSettings({ favorites: nextFavorites }); + }, + [favorites, updateSettings], + ); + + const isLocked = props.lockedProvider !== null; + const isSearching = searchQuery.trim().length > 0; + const showSidebar = !isLocked && !isSearching; + const LockedProviderIcon = + isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; + const modelJumpCommandByKey = useMemo(() => { + const mapping = new Map< + string, + NonNullable> + >(); + for (const [visibleModelIndex, model] of filteredModels.entries()) { + const jumpCommand = modelPickerJumpCommandForIndex(visibleModelIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(`${model.provider}:${model.slug}`, jumpCommand); + } + return mapping; + }, [filteredModels]); + const modelJumpModelKeys = useMemo( + () => [...modelJumpCommandByKey.keys()], + [modelJumpCommandByKey], + ); + const modelJumpShortcutContext = useMemo( + () => + ({ + terminalFocus: false, + terminalOpen: props.terminalOpen ?? false, + modelPickerOpen: true, + }) as const, + [props.terminalOpen], + ); + const modelJumpLabelByKey = useMemo((): ReadonlyMap => { + if (modelJumpCommandByKey.size === 0) { + return EMPTY_MODEL_JUMP_LABELS; + } + const shortcutLabelOptions = { + platform: navigator.platform, + context: modelJumpShortcutContext, + }; + const mapping = new Map(); + for (const [modelKey, command] of modelJumpCommandByKey) { + const label = shortcutLabelForCommand(keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(modelKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; + }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); + + useEffect(() => { + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: modelJumpShortcutContext, + }); + const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetModelKey = modelJumpModelKeys[jumpIndex]; + if (!targetModelKey) { + return; + } + const [provider, slug] = targetModelKey.split(":") as [ProviderKind, string]; + event.preventDefault(); + event.stopPropagation(); + handleModelSelect(slug, provider); + }; + + window.addEventListener("keydown", onWindowKeyDown, true); + return () => { + window.removeEventListener("keydown", onWindowKeyDown, true); + }; + }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + + useLayoutEffect(() => { + const listRegion = listRegionRef.current; + if (!listRegion) { + return; + } + + let cancelled = false; + let frame = 0; + let nestedFrame = 0; + let timeout = 0; + + const measureScrollArea = () => { + if (cancelled) { + return; + } + const viewport = listRegion.querySelector('[data-slot="scroll-area-viewport"]'); + if (!viewport || viewport.scrollHeight <= viewport.clientHeight) { + return; + } + const originalScrollTop = viewport.scrollTop; + const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; + if (maxScrollTop <= 0) { + return; + } + viewport.scrollTop = Math.min(originalScrollTop + 1, maxScrollTop); + viewport.scrollTop = originalScrollTop; + }; + + queueMicrotask(measureScrollArea); + frame = window.requestAnimationFrame(() => { + measureScrollArea(); + nestedFrame = window.requestAnimationFrame(measureScrollArea); + }); + timeout = window.setTimeout(measureScrollArea, 0); + + return () => { + cancelled = true; + window.cancelAnimationFrame(frame); + window.cancelAnimationFrame(nestedFrame); + window.clearTimeout(timeout); + }; + }, [filteredModelKeys]); + + const comboboxValue = props.showAsAuto + ? `${props.provider}:${COMPOSER_AUTO_MODEL_VALUE}` + : `${props.provider}:${props.model}`; + + return ( + +
+ {isLocked && LockedProviderIcon && props.lockedProvider ? ( +
+ + + {PROVIDER_DISPLAY_NAMES[props.lockedProvider]} + +
+ ) : null} + + {showSidebar ? ( + + ) : null} + + { + highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; + }} + onValueChange={(modelKey) => { + if (typeof modelKey !== "string") { + return; + } + const [provider, slug] = modelKey.split(":") as [ProviderKind, string]; + handleModelSelect(slug, provider); + }} + > +
+
+ } + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + props.onRequestClose?.(); + return; + } + if (event.key === "Enter" && highlightedModelKeyRef.current) { + ( + event as typeof event & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + const [provider, slug] = highlightedModelKeyRef.current.split(":") as [ + ProviderKind, + string, + ]; + handleModelSelect(slug, provider); + return; + } + event.stopPropagation(); + }} + onMouseDown={(event) => event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + size="sm" + /> +
+ +
+ + {filteredModelKeys.map((modelKey, index) => { + const model = filteredModelByKey.get(modelKey); + if (!model) { + return null; + } + return ( + toggleFavorite(model.provider, model.slug)} + /> + ); + })} + +
+ + No models found + +
+
+
+
+ ); +}); diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx new file mode 100644 index 00000000..30ef9825 --- /dev/null +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -0,0 +1,166 @@ +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { memo } from "react"; +import { Clock3Icon, StarIcon } from "lucide-react"; + +import { CursorIcon } from "../Icons"; +import { + AVAILABLE_PROVIDER_OPTIONS, + PROVIDER_ICON_BY_PROVIDER, + PROVIDER_TINT_CLASS_BY_PROVIDER, +} from "./providerIconUtils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; +import { getProviderSnapshot } from "../../providerModels"; + +function describeUnavailableProvider(label: string, live: ServerProvider | undefined): string { + if (!live) { + return `${label} — waiting for provider status…`; + } + if (live.status === "ready") { + return label; + } + const kind = + live.status === "error" + ? "Unavailable" + : live.status === "warning" + ? "Limited" + : live.status === "disabled" + ? "Disabled in settings" + : "Not ready"; + const message = live.message?.trim(); + return message ? `${label} — ${kind}. ${message}` : `${label} — ${kind}.`; +} + +const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; +const SELECTED_INDICATOR_CLASS = + "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.5 -translate-y-1/2 rounded-l-full bg-primary"; +const PICKER_TOOLTIP_SIDE = "left" as const; +const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; +const SOON_BADGE_CLASS = + "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent text-muted-foreground shadow-sm"; + +export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { + selectedProvider: ProviderKind | "favorites"; + onSelectProvider: (provider: ProviderKind | "favorites") => void; + providers?: ReadonlyArray; +}) { + return ( +
+
+
+ {props.selectedProvider === "favorites" ? ( +
+ ) : null} + + props.onSelectProvider("favorites")} + type="button" + data-model-picker-provider="favorites" + aria-label="Favorites" + > + + + } + /> + + Favorites + + +
+
+ + {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; + const liveProvider = props.providers + ? getProviderSnapshot(props.providers, option.value) + : undefined; + const isDisabled = !liveProvider || liveProvider.status !== "ready"; + const isSelected = props.selectedProvider === option.value; + const tooltipText = isDisabled + ? describeUnavailableProvider(option.label, liveProvider) + : option.label; + + const button = ( + + ); + + return ( +
+ {isSelected ?
: null} + + + + {tooltipText} + + +
+ ); + })} + + + + + + } + /> + + Cursor — Coming soon + + +
+ ); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 703bfada..172da9ff 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -10,6 +10,7 @@ import { AntigravityIcon, CursorIcon, Icon, + KiroIcon, TraeIcon, IntelliJIdeaIcon, VisualStudioCode, @@ -30,6 +31,11 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray = [ }, ]; -function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { - return { - provider: "codex", - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: new Date().toISOString(), - models, - }; -} - async function mountPicker(props: { provider: ProviderKind; model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; + keybindings?: ResolvedKeybindingsConfig; triggerVariant?: "ghost" | "outline"; showAsAuto?: boolean; }) { @@ -148,6 +140,7 @@ async function mountPicker(props: { lockedProvider={props.lockedProvider} providers={providers} modelOptionsByProvider={modelOptionsByProvider} + {...(props.keybindings ? { keybindings: props.keybindings } : {})} {...(props.showAsAuto !== undefined ? { showAsAuto: props.showAsAuto } : {})} triggerVariant={props.triggerVariant} onProviderModelChange={onProviderModelChange} @@ -164,33 +157,40 @@ async function mountPicker(props: { }; } -describe("ProviderModelPicker", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); +function getModelPickerListElement() { + const modelPickerList = document.querySelector(".model-picker-list"); + expect(modelPickerList).not.toBeNull(); + return modelPickerList!; +} - it("shows provider submenus when provider switching is allowed", async () => { - const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: null, - }); +function getVisibleModelNames() { + return Array.from( + getModelPickerListElement().querySelectorAll('[data-slot="combobox-item"]'), + ) + .map((element) => element.textContent?.replace(/New$/u, "").trim() ?? "") + .filter((text) => text.length > 0); +} - try { - await page.getByRole("button").click(); +function getSidebarProviderOrder() { + return Array.from(document.querySelectorAll("[data-model-picker-provider]")).map( + (element) => element.dataset.modelPickerProvider ?? "", + ); +} - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Codex"); - expect(text).toContain("Claude"); - expect(text).not.toContain("Claude Sonnet 4.6"); - }); - } finally { - await mounted.cleanup(); - } +function clickComboboxItem(label: string) { + const item = Array.from( + document.querySelectorAll('[data-slot="combobox-item"]'), + ).find((element) => element.textContent?.includes(label)); + expect(item).toBeTruthy(); + item?.click(); +} + +describe("ProviderModelPicker", () => { + afterEach(() => { + document.body.innerHTML = ""; }); - it("opens provider submenus with a visible gap from the parent menu", async () => { + it("shows provider sidebar in unlocked mode", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -199,37 +199,18 @@ describe("ProviderModelPicker", () => { try { await page.getByRole("button").click(); - const providerTrigger = page.getByRole("menuitem", { name: "Codex" }); - await providerTrigger.hover(); await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("GPT-5 Codex"); + expect(getSidebarProviderOrder().slice(0, 3)).toEqual([ + "favorites", + "codex", + "claudeAgent", + ]); + expect(getVisibleModelNames().some((name) => name.includes("Auto"))).toBe(true); + expect(getVisibleModelNames().some((name) => name.includes("Claude Sonnet 4.6"))).toBe( + true, + ); }); - - const providerTriggerElement = Array.from( - document.querySelectorAll('[role="menuitem"]'), - ).find((element) => element.textContent?.includes("Codex")); - if (!providerTriggerElement) { - throw new Error("Expected the Codex provider trigger to be mounted."); - } - - const providerTriggerRect = providerTriggerElement.getBoundingClientRect(); - const modelElement = Array.from( - document.querySelectorAll('[role="menuitemradio"]'), - ).find((element) => element.textContent?.includes("GPT-5 Codex")); - if (!modelElement) { - throw new Error("Expected the submenu model option to be mounted."); - } - - const submenuPopup = modelElement.closest('[data-slot="menu-sub-content"]'); - if (!(submenuPopup instanceof HTMLElement)) { - throw new Error("Expected submenu popup to be mounted."); - } - - const submenuRect = submenuPopup.getBoundingClientRect(); - - expect(submenuRect.left).toBeGreaterThanOrEqual(providerTriggerRect.right); - expect(submenuRect.left - providerTriggerRect.right).toBeGreaterThanOrEqual(2); } finally { await mounted.cleanup(); } @@ -246,129 +227,44 @@ describe("ProviderModelPicker", () => { await page.getByRole("button").click(); await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude Sonnet 4.6"); - expect(text).toContain("Claude Haiku 4.5"); - expect(text).not.toContain("Codex"); + expect(document.querySelectorAll("[data-model-picker-provider]").length).toBe(0); + expect(getVisibleModelNames().some((name) => name.includes("Claude Sonnet 4.6"))).toBe( + true, + ); + expect(getVisibleModelNames().some((name) => name.includes("GPT-5 Codex"))).toBe(false); }); } finally { await mounted.cleanup(); } }); - it("shows and dispatches Auto when selected", async () => { + it("filters models with search", async () => { const mounted = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: "claudeAgent", - showAsAuto: true, - }); - - try { - await page.getByRole("button").click(); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Auto"); - }); - - await page.getByRole("menuitemradio", { name: "Auto" }).click(); - expect(mounted.onProviderModelChange).toHaveBeenCalledWith( - "claudeAgent", - COMPOSER_AUTO_MODEL_VALUE, - ); - } finally { - await mounted.cleanup(); - } - }); - - it("only shows codex spark when the server reports it for the account", async () => { - const providersWithoutSpark: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ]), - TEST_PROVIDERS[1]!, - ]; - const providersWithSpark: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ]), - TEST_PROVIDERS[1]!, - ]; - - const hidden = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", lockedProvider: null, - providers: providersWithoutSpark, }); try { await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("GPT-5.3 Codex"); - expect(text).not.toContain("GPT-5.3 Codex Spark"); + expect(document.querySelector(".model-picker-list")).not.toBeNull(); }); - } finally { - await hidden.cleanup(); - } - const visible = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: null, - providers: providersWithSpark, - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); + await userEvent.fill(page.getByPlaceholder("Search models..."), "sonnet"); await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); + const listText = getModelPickerListElement().textContent ?? ""; + expect(listText).toContain("Claude Sonnet 4.6"); + expect(listText).not.toContain("Claude Opus 4.6"); + expect(listText).not.toContain("Claude Haiku 4.5"); }); } finally { - await visible.cleanup(); + await mounted.cleanup(); } }); - it("dispatches the canonical slug when a model is selected", async () => { + it("dispatches Auto when selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", model: "claude-opus-4-6", @@ -377,45 +273,51 @@ describe("ProviderModelPicker", () => { try { await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "Claude Sonnet 4.6" }).click(); + await vi.waitFor(() => { + expect(document.querySelector(".model-picker-list")).not.toBeNull(); + }); + clickComboboxItem("Auto"); expect(mounted.onProviderModelChange).toHaveBeenCalledWith( "claudeAgent", - "claude-sonnet-4-6", + COMPOSER_AUTO_MODEL_VALUE, ); } finally { await mounted.cleanup(); } }); - it("shows disabled providers as non-selectable entries", async () => { - const disabledProviders = TEST_PROVIDERS.slice(); - const claudeIndex = disabledProviders.findIndex( - (provider) => provider.provider === "claudeAgent", - ); - if (claudeIndex >= 0) { - const claudeProvider = disabledProviders[claudeIndex]!; - disabledProviders[claudeIndex] = { - ...claudeProvider, - enabled: false, - status: "disabled", - }; - } + it("shows jump shortcut labels when keybindings are provided", async () => { const mounted = await mountPicker({ provider: "codex", model: "gpt-5-codex", - lockedProvider: null, - providers: disabledProviders, + lockedProvider: "codex", + keybindings: [ + { + command: "modelPicker.jump.1", + shortcut: { + key: "1", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { type: "identifier", name: "modelPickerOpen" }, + }, + ], }); try { await page.getByRole("button").click(); + const jumpLabel = navigator.platform.includes("Mac") ? "⌘1" : "Ctrl+1"; await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Claude"); - expect(text).toContain("Disabled"); - expect(text).not.toContain("Claude Sonnet 4.6"); + expect( + Array.from( + document.querySelectorAll('.model-picker-list [data-slot="kbd"]'), + ).some((element) => element.textContent?.trim() === jumpLabel), + ).toBe(true); }); } finally { await mounted.cleanup(); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 83e21a62..74db8970 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,99 +1,85 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { resolveSelectableModel } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { + type ProviderKind, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { memo, useEffect, useState } from "react"; import type { VariantProps } from "class-variance-authority"; -import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; -import { - Menu, - MenuGroup, - MenuItem, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator as MenuDivider, - MenuSub, - MenuSubPopup, - MenuSubTrigger, - MenuTrigger, -} from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { getProviderSnapshot } from "../../providerModels"; - -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; - label: string; - available: true; -} { - return option.available; -} - -const PROVIDER_ICON_BY_PROVIDER: Record = { - codex: OpenAI, - claudeAgent: ClaudeAI, - cursor: CursorIcon, -}; - -export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -export const COMPOSER_AUTO_MODEL_VALUE = "auto"; -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); -const COMING_SOON_PROVIDER_OPTIONS = [ - { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, - { id: "gemini", label: "Gemini", icon: Gemini }, -] as const; +import { ModelPickerContent, COMPOSER_AUTO_MODEL_VALUE } from "./ModelPickerContent"; +import { + type ModelEsque, + PROVIDER_ICON_BY_PROVIDER, + PROVIDER_TINT_CLASS_BY_PROVIDER, + getTriggerDisplayModelLabel, + getTriggerDisplayModelName, +} from "./providerIconUtils"; +import { setModelPickerOpen } from "../../modelPickerOpenState"; -function providerIconClassName( - provider: ProviderKind | ProviderPickerKind, - fallbackClassName: string, -): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; -} +export { COMPOSER_AUTO_MODEL_VALUE } from "./ModelPickerContent"; +export { AVAILABLE_PROVIDER_OPTIONS } from "./providerIconUtils"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: string; lockedProvider: ProviderKind | null; providers?: ReadonlyArray; - modelOptionsByProvider: Record>; + keybindings?: ResolvedKeybindingsConfig; + modelOptionsByProvider: Record>; activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; showAsAuto?: boolean; + terminalOpen?: boolean; + open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + onOpenChange?: (open: boolean) => void; onProviderModelChange: (provider: ProviderKind, model: string) => void; }) { - const [isMenuOpen, setIsMenuOpen] = useState(false); + const [uncontrolledIsMenuOpen, setUncontrolledIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; + const isMenuOpen = props.open ?? uncontrolledIsMenuOpen; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; - const selectedModelLabel = props.showAsAuto - ? "Auto" - : selectedProviderOptions.find((option) => option.slug === props.model)?.name || - props.model || - "Current Model"; + // Fall back to the active provider's first model so the trigger never renders + // a stale slug after provider switches or partial status refreshes. + const selectedModel = props.showAsAuto + ? ({ slug: COMPOSER_AUTO_MODEL_VALUE, name: "Auto" } satisfies ModelEsque) + : (selectedProviderOptions.find((option) => option.slug === props.model) ?? + selectedProviderOptions[0]); const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; - const handleModelChange = (provider: ProviderKind, value: string) => { - if (props.disabled) return; - if (!value) return; - if (value === COMPOSER_AUTO_MODEL_VALUE) { - props.onProviderModelChange(provider, COMPOSER_AUTO_MODEL_VALUE); - setIsMenuOpen(false); + const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; + const triggerSubtitle = selectedModel?.subProvider; + const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; + + const setIsMenuOpen = (open: boolean) => { + props.onOpenChange?.(open); + if (props.open === undefined) { + setUncontrolledIsMenuOpen(open); + } + }; + + useEffect(() => { + setModelPickerOpen(isMenuOpen); + return () => { + setModelPickerOpen(false); + }; + }, [isMenuOpen]); + + const handleProviderModelChange = (provider: ProviderKind, model: string) => { + if (props.disabled) { return; } - const resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); - if (!resolvedModel) return; - props.onProviderModelChange(provider, resolvedModel); + props.onProviderModelChange(provider, model); setIsMenuOpen(false); }; return ( - { if (props.disabled) { @@ -103,7 +89,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { setIsMenuOpen(open); }} > - - {props.model && ( - - - {props.lockedProvider !== null ? ( - - handleModelChange(props.lockedProvider!, value)} - > - Auto - {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - ) : ( - <> - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - const liveProvider = props.providers - ? getProviderSnapshot(props.providers, option.value) - : undefined; - if (liveProvider && liveProvider.status !== "ready") { - const unavailableLabel = !liveProvider.enabled - ? "Disabled" - : !liveProvider.installed - ? "Not installed" - : "Unavailable"; - return ( - - - ); - } - return ( - - - - - - handleModelChange(option.value, value)} - > - Auto - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; - return ( - - - ); - })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } - {COMING_SOON_PROVIDER_OPTIONS.map((option) => { - const OptionIcon = option.icon; - return ( - - - ); - })} - - )} - - + + + setIsMenuOpen(false)} + onProviderModelChange={handleProviderModelChange} + /> + + ); }); diff --git a/apps/web/src/components/chat/modelPickerModelHighlights.ts b/apps/web/src/components/chat/modelPickerModelHighlights.ts new file mode 100644 index 00000000..9afc33a5 --- /dev/null +++ b/apps/web/src/components/chat/modelPickerModelHighlights.ts @@ -0,0 +1,11 @@ +import type { ProviderKind } from "@t3tools/contracts"; + +/** + * Keep release-callout logic centralized so future model launches do not need + * ad hoc badge checks spread across the picker rows. + */ +const NEW_MODEL_KEYS = new Set([]); + +export function isModelPickerNewModel(provider: ProviderKind, slug: string): boolean { + return NEW_MODEL_KEYS.has(`${provider}:${slug}`); +} diff --git a/apps/web/src/components/chat/modelPickerSearch.test.ts b/apps/web/src/components/chat/modelPickerSearch.test.ts new file mode 100644 index 00000000..1e322c95 --- /dev/null +++ b/apps/web/src/components/chat/modelPickerSearch.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; + +describe("buildModelPickerSearchText", () => { + it("builds provider-aware search text from generic fields", () => { + expect( + buildModelPickerSearchText({ + provider: "claudeAgent", + name: "Claude Opus 4.6", + subProvider: "Anthropic", + }), + ).toBe("claude opus 4.6 anthropic claudeagent claude"); + }); +}); + +describe("scoreModelPickerSearch", () => { + it("matches typo-tolerant multi-token queries", () => { + expect( + scoreModelPickerSearch( + { + provider: "claudeAgent", + name: "Claude Opus 4.6", + subProvider: "Anthropic", + }, + "anthr opu", + ), + ).not.toBeNull(); + }); + + it("rejects results when any query token does not match", () => { + expect( + scoreModelPickerSearch( + { + provider: "codex", + name: "GPT-5 Codex", + }, + "anthr opu", + ), + ).toBeNull(); + }); + + it("ranks exact token matches ahead of fuzzier matches", () => { + const exactScore = scoreModelPickerSearch( + { + provider: "claudeAgent", + name: "Claude Opus 4.6", + }, + "opus claude", + ); + const fuzzyScore = scoreModelPickerSearch( + { + provider: "claudeAgent", + name: "Claude Opus 4.6", + }, + "opu clde", + ); + + expect(exactScore).not.toBeNull(); + expect(fuzzyScore).not.toBeNull(); + expect(exactScore!).toBeLessThan(fuzzyScore!); + }); + + it("gives favorite models a ranking boost for partial queries", () => { + const favoriteScore = scoreModelPickerSearch( + { + provider: "claudeAgent", + name: "Claude Opus 4.6", + isFavorite: true, + }, + "opu", + ); + const nonFavoriteScore = scoreModelPickerSearch( + { + provider: "codex", + name: "Opus-compatible", + }, + "opu", + ); + + expect(favoriteScore).not.toBeNull(); + expect(nonFavoriteScore).not.toBeNull(); + expect(favoriteScore!).toBeLessThan(nonFavoriteScore!); + }); +}); diff --git a/apps/web/src/components/chat/modelPickerSearch.ts b/apps/web/src/components/chat/modelPickerSearch.ts new file mode 100644 index 00000000..f4def179 --- /dev/null +++ b/apps/web/src/components/chat/modelPickerSearch.ts @@ -0,0 +1,108 @@ +import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; + +type ModelPickerSearchableModel = { + provider: ProviderKind; + name: string; + shortName?: string; + subProvider?: string; + isFavorite?: boolean; +}; + +const MODEL_PICKER_FAVORITE_SCORE_BOOST = 8; + +function normalizeSearchQuery(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9.\s-]+/giu, " ") + .replace(/\s+/gu, " ") + .trim(); +} + +function isSubsequenceMatch(value: string, query: string): boolean { + let valueIndex = 0; + let queryIndex = 0; + + while (valueIndex < value.length && queryIndex < query.length) { + if (value[valueIndex] === query[queryIndex]) { + queryIndex += 1; + } + valueIndex += 1; + } + + return queryIndex === query.length; +} + +function getModelPickerSearchFields(model: ModelPickerSearchableModel): string[] { + return [ + normalizeSearchQuery(model.name), + ...(model.shortName ? [normalizeSearchQuery(model.shortName)] : []), + ...(model.subProvider ? [normalizeSearchQuery(model.subProvider)] : []), + normalizeSearchQuery(model.provider), + normalizeSearchQuery(PROVIDER_DISPLAY_NAMES[model.provider]), + buildModelPickerSearchText(model), + ]; +} + +function scoreModelPickerSearchToken( + field: string, + token: string, + fieldBase: number, +): number | null { + if (field === token) { + return fieldBase; + } + if (field.startsWith(token)) { + return fieldBase + 2; + } + if (field.includes(token)) { + return fieldBase + 6; + } + if (token.length >= 3 && isSubsequenceMatch(field, token)) { + return fieldBase + 20; + } + return null; +} + +export function buildModelPickerSearchText(model: ModelPickerSearchableModel): string { + return normalizeSearchQuery( + [ + model.name, + model.shortName, + model.subProvider, + model.provider, + PROVIDER_DISPLAY_NAMES[model.provider], + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" "), + ); +} + +export function scoreModelPickerSearch( + model: ModelPickerSearchableModel, + query: string, +): number | null { + const tokens = normalizeSearchQuery(query) + .split(/\s+/u) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + return 0; + } + + const fields = getModelPickerSearchFields(model); + let score = 0; + + for (const token of tokens) { + const tokenScores = fields + .map((field, index) => scoreModelPickerSearchToken(field, token, index * 10)) + .filter((fieldScore): fieldScore is number => fieldScore !== null); + + if (tokenScores.length === 0) { + return null; + } + + score += Math.min(...tokenScores); + } + + return model.isFavorite ? score - MODEL_PICKER_FAVORITE_SCORE_BOOST : score; +} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts new file mode 100644 index 00000000..ca530527 --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -0,0 +1,56 @@ +import { type ProviderKind, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { ClaudeAI, CursorIcon, type Icon, OpenAI } from "../Icons"; +import { PROVIDER_OPTIONS } from "../../session-logic"; + +export const PROVIDER_ICON_BY_PROVIDER: Record = { + codex: OpenAI, + claudeAgent: ClaudeAI, + cursor: CursorIcon, +}; + +export const PROVIDER_TINT_CLASS_BY_PROVIDER: Record = { + codex: "text-neutral-900 dark:text-white", + claudeAgent: "text-orange-500 dark:text-orange-300", + cursor: "text-violet-500 dark:text-violet-400", +}; + +function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { + value: ProviderKind; + label: string; + available: true; +} { + return option.available; +} + +export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); + +export type ModelEsque = { + slug: string; + name: string; + shortName?: string; + subProvider?: string; +}; + +export function getProviderLabel(provider: ProviderKind, model: ModelEsque): string { + const providerLabel = PROVIDER_DISPLAY_NAMES[provider]; + return model.subProvider ? `${providerLabel} · ${model.subProvider}` : providerLabel; +} + +export function getDisplayModelName( + model: ModelEsque, + options?: { preferShortName?: boolean }, +): string { + if (options?.preferShortName && model.shortName) { + return model.shortName; + } + return model.name; +} + +export function getTriggerDisplayModelName(model: ModelEsque): string { + return getDisplayModelName(model, { preferShortName: true }); +} + +export function getTriggerDisplayModelLabel(model: ModelEsque): string { + const title = getTriggerDisplayModelName(model); + return model.subProvider ? `${model.subProvider} · ${title}` : title; +} diff --git a/apps/web/src/components/settings/SettingsAppearancePanel.tsx b/apps/web/src/components/settings/SettingsAppearancePanel.tsx index 55f01dc0..752c81cd 100644 --- a/apps/web/src/components/settings/SettingsAppearancePanel.tsx +++ b/apps/web/src/components/settings/SettingsAppearancePanel.tsx @@ -23,6 +23,11 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const PROJECT_PICKER_MODE_LABELS = { + fullscreen: "Fullscreen", + sidebar: "Sidebar", +} as const; + export function SettingsAppearancePanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -64,6 +69,45 @@ export function SettingsAppearancePanel() { } /> + + updateSettings({ + projectPickerMode: DEFAULT_UNIFIED_SETTINGS.projectPickerMode, + }) + } + /> + ) : null + } + control={ + + } + /> + void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(settings.projectPickerMode !== DEFAULT_UNIFIED_SETTINGS.projectPickerMode + ? ["Project picker"] + : []), + ...(settings.favorites.length > 0 ? ["Favorite models"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -94,7 +98,9 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.favorites.length, settings.planModelSelection, + settings.projectPickerMode, settings.promptEnhancePreset, settings.reviewModelSelection, settings.timestampFormat, diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 832ee17f..4b8c2928 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { buildLegacyClientSettingsMigrationPatch } from "./useSettings"; describe("buildLegacyClientSettingsMigrationPatch", () => { + it("defaults the project picker to fullscreen", () => { + expect(DEFAULT_CLIENT_SETTINGS.projectPickerMode).toBe("fullscreen"); + }); + it("migrates archive confirmation from legacy local settings", () => { expect( buildLegacyClientSettingsMigrationPatch({ @@ -13,4 +18,32 @@ describe("buildLegacyClientSettingsMigrationPatch", () => { confirmThreadDelete: false, }); }); + + it("migrates the project picker preference from legacy local settings", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + projectPickerMode: "sidebar", + }), + ).toEqual({ + projectPickerMode: "sidebar", + }); + }); + + it("migrates favorite models from legacy local settings", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + favorites: [ + { provider: "codex", model: "gpt-5.4" }, + { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + { provider: "cursor", model: "ignore-me" }, + { provider: "codex", model: "" }, + ], + }), + ).toEqual({ + favorites: [ + { provider: "codex", model: "gpt-5.4" }, + { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + ], + }); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index dbcd3118..e0d3c17b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -16,6 +16,7 @@ import { ClientSettingsSchema, DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS, + ProjectPickerMode, SidebarProjectSortOrder, SidebarThreadSortOrder, TimestampFormat, @@ -202,6 +203,31 @@ export function buildLegacyClientSettingsMigrationPatch( patch.diffWordWrap = legacySettings.diffWordWrap; } + if (Array.isArray(legacySettings.favorites)) { + const favorites = legacySettings.favorites.flatMap((favorite) => { + if (!Predicate.isObject(favorite)) { + return []; + } + const provider = favorite.provider; + const model = favorite.model; + if ( + (provider === "codex" || provider === "claudeAgent") && + typeof model === "string" && + model.trim().length > 0 + ) { + return [{ provider, model: model.trim() }] as const; + } + return []; + }); + if (favorites.length > 0) { + patch.favorites = favorites; + } + } + + if (Schema.is(ProjectPickerMode)(legacySettings.projectPickerMode)) { + patch.projectPickerMode = legacySettings.projectPickerMode; + } + if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9c664b5b..6dcb0d96 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -214,6 +214,27 @@ input { background: rgba(255, 255, 255, 0.18); } +.model-picker-list::-webkit-scrollbar { + width: 4px; +} + +.model-picker-list::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 2px; +} + +.model-picker-list::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.2); +} + +.dark .model-picker-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); +} + +.dark .model-picker-list::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); +} + .turn-chip-strip { scrollbar-width: none; -ms-overflow-style: none; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c923f4ff..44d16ac1 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -11,6 +11,8 @@ import { isChatNewShortcut, isChatNewLocalShortcut, isDiffToggleShortcut, + modelPickerJumpCommandForIndex, + modelPickerJumpIndexFromCommand, isOpenFavoriteEditorShortcut, isTerminalClearShortcut, isTerminalCloseShortcut, @@ -107,12 +109,32 @@ const DEFAULT_BINDINGS = compile([ }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, + { + shortcut: modShortcut("m", { shiftKey: true }), + command: "modelPicker.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, { shortcut: modShortcut("1"), command: "thread.jump.1" }, { shortcut: modShortcut("2"), command: "thread.jump.2" }, { shortcut: modShortcut("3"), command: "thread.jump.3" }, + { + shortcut: modShortcut("1"), + command: "modelPicker.jump.1", + whenAst: whenIdentifier("modelPickerOpen"), + }, + { + shortcut: modShortcut("2"), + command: "modelPicker.jump.2", + whenAst: whenIdentifier("modelPickerOpen"), + }, + { + shortcut: modShortcut("3"), + command: "modelPicker.jump.3", + whenAst: whenIdentifier("modelPickerOpen"), + }, ]); describe("isTerminalToggleShortcut", () => { @@ -129,6 +151,15 @@ describe("isTerminalToggleShortcut", () => { isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" }), ); }); + + it("matches Ctrl+J on non-macOS while terminalFocus is true", () => { + assert.isTrue( + isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { terminalFocus: true }, + }), + ); + }); }); describe("sidebar toggle shortcut", () => { @@ -160,6 +191,35 @@ describe("sidebar toggle shortcut", () => { }); }); +describe("model picker shortcuts", () => { + it("resolves the toggle binding when the terminal is not focused", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "m", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "modelPicker.toggle", + ); + }); + + it("prefers model jump bindings over thread jumps while the picker is open", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "1", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { modelPickerOpen: true }, + }), + "modelPicker.jump.1", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "1", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Win32", + context: { modelPickerOpen: false }, + }), + "thread.jump.1", + ); + }); +}); + describe("split/new/close terminal shortcuts", () => { it("requires terminalFocus for default split/new/close bindings", () => { assert.isFalse( @@ -298,6 +358,13 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), "Ctrl+Shift+[", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.jump.1", { + platform: "Linux", + context: { modelPickerOpen: true }, + }), + "Ctrl+1", + ); }); it("returns null for commands shadowed by a later conflicting shortcut", () => { @@ -353,6 +420,15 @@ describe("thread navigation helpers", () => { assert.isNull(threadJumpIndexFromCommand("thread.next")); }); + it("maps model jump commands to visible picker indices", () => { + assert.strictEqual(modelPickerJumpCommandForIndex(0), "modelPicker.jump.1"); + assert.strictEqual(modelPickerJumpCommandForIndex(2), "modelPicker.jump.3"); + assert.isNull(modelPickerJumpCommandForIndex(9)); + assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.1"), 0); + assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); + assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); + }); + it("maps traversal commands to directions", () => { assert.strictEqual(threadTraversalDirectionFromCommand("thread.previous"), "previous"); assert.strictEqual(threadTraversalDirectionFromCommand("thread.next"), "next"); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 286454dc..9f6fe146 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -2,6 +2,8 @@ import { type KeybindingCommand, type KeybindingShortcut, type KeybindingWhenNode, + MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, + type ModelPickerJumpKeybindingCommand, type ResolvedKeybindingsConfig, THREAD_JUMP_KEYBINDING_COMMANDS, type ThreadJumpKeybindingCommand, @@ -21,6 +23,7 @@ export interface ShortcutEventLike { export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; + modelPickerOpen: boolean; [key: string]: boolean; } @@ -102,6 +105,7 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc return { terminalFocus: false, terminalOpen: false, + modelPickerOpen: false, ...options?.context, }; } @@ -256,6 +260,19 @@ export function threadJumpIndexFromCommand(command: string): number | null { return index === -1 ? null : index; } +export function modelPickerJumpCommandForIndex( + index: number, +): ModelPickerJumpKeybindingCommand | null { + return MODEL_PICKER_JUMP_KEYBINDING_COMMANDS[index] ?? null; +} + +export function modelPickerJumpIndexFromCommand(command: string): number | null { + const index = MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.indexOf( + command as ModelPickerJumpKeybindingCommand, + ); + return index === -1 ? null : index; +} + export function threadTraversalDirectionFromCommand( command: string | null, ): "previous" | "next" | null { diff --git a/apps/web/src/lib/projectPaths.test.ts b/apps/web/src/lib/projectPaths.test.ts new file mode 100644 index 00000000..0c21c979 --- /dev/null +++ b/apps/web/src/lib/projectPaths.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, + findProjectByPath, + getBrowseParentPath, + inferProjectTitleFromPath, + isFilesystemBrowseQuery, + normalizeProjectPathForDispatch, + resolveProjectPathForDispatch, +} from "./projectPaths"; + +describe("projectPaths", () => { + it("normalizes paths for dispatch", () => { + expect(normalizeProjectPathForDispatch("/tmp/project/")).toBe("/tmp/project"); + expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); + }); + + it("resolves explicit relative paths against an absolute cwd", () => { + expect(resolveProjectPathForDispatch("./child", "/tmp/project")).toBe("/tmp/project/child"); + expect(resolveProjectPathForDispatch("../sibling", "/tmp/project")).toBe("/tmp/sibling"); + }); + + it("detects browse-like queries", () => { + expect(isFilesystemBrowseQuery("~/code")).toBe(true); + expect(isFilesystemBrowseQuery("/tmp")).toBe(true); + expect(isFilesystemBrowseQuery("repo")).toBe(false); + expect(isFilesystemBrowseQuery("C:\\repo", "Win32")).toBe(true); + }); + + it("matches projects by normalized cwd", () => { + const project = { cwd: "/tmp/project" }; + expect(findProjectByPath([project], "/tmp/project/")).toBe(project); + }); + + it("infers a title from the leaf path segment", () => { + expect(inferProjectTitleFromPath("/tmp/project")).toBe("project"); + expect(inferProjectTitleFromPath("C:\\Users\\cj\\repo")).toBe("repo"); + }); + + it("supports browse navigation helpers", () => { + expect(ensureBrowseDirectoryPath("/tmp/project")).toBe("/tmp/project/"); + expect(appendBrowsePathSegment("/tmp/project/", "src")).toBe("/tmp/project/src/"); + expect(getBrowseParentPath("/tmp/project/src/")).toBe("/tmp/project/"); + expect(canNavigateUp("/tmp/project/src/")).toBe(true); + expect(getBrowseParentPath("/")).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts new file mode 100644 index 00000000..ac2e04d6 --- /dev/null +++ b/apps/web/src/lib/projectPaths.ts @@ -0,0 +1,266 @@ +import { isWindowsPlatform } from "./utils"; + +function isWindowsDrivePath(value: string): boolean { + return /^[a-zA-Z]:([/\\]|$)/.test(value); +} + +function isUncPath(value: string): boolean { + return value.startsWith("\\\\"); +} + +function isWindowsAbsolutePath(value: string): boolean { + return isUncPath(value) || isWindowsDrivePath(value); +} + +function isExplicitRelativePath(value: string): boolean { + return ( + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") + ); +} + +function isRootPath(value: string): boolean { + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); +} + +function getAbsolutePathKind(value: string): "unix" | "windows" | null { + if (isWindowsDrivePath(value) || isUncPath(value)) { + return "windows"; + } + + if (value.startsWith("/")) { + return "unix"; + } + + return null; +} + +function trimTrailingPathSeparators(value: string): string { + if (value.length === 0 || isRootPath(value)) { + return value; + } + + const trimmed = + getAbsolutePathKind(value) === "unix" + ? value.replace(/\/+$/g, "") + : value.replace(/[\\/]+$/g, ""); + if (trimmed.length === 0) { + return value; + } + + return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed; +} + +function preferredPathSeparator(value: string): "/" | "\\" { + const absolutePathKind = getAbsolutePathKind(value); + if (absolutePathKind === "windows") { + return "\\"; + } + if (absolutePathKind === "unix") { + return "/"; + } + + return value.includes("\\") ? "\\" : "/"; +} + +function splitPathSegments(value: string, separator: "/" | "\\"): string[] { + return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean); +} + +function getLastPathSeparatorIndex(value: string): number { + if (getAbsolutePathKind(value) === "unix") { + return value.lastIndexOf("/"); + } + + return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")); +} + +function splitAbsolutePath(value: string): { + root: string; + separator: "/" | "\\"; + segments: string[]; +} | null { + if (isWindowsDrivePath(value)) { + const root = `${value.slice(0, 2)}\\`; + const segments = splitPathSegments(value.slice(root.length), "\\"); + return { root, separator: "\\", segments }; + } + if (isUncPath(value)) { + const segments = splitPathSegments(value, "\\"); + const [server, share, ...rest] = segments; + if (!server || !share) { + return null; + } + return { + root: `\\\\${server}\\${share}\\`, + separator: "\\", + segments: rest, + }; + } + if (value.startsWith("/")) { + return { + root: "/", + separator: "/", + segments: splitPathSegments(value.slice(1), "/"), + }; + } + return null; +} + +export function hasTrailingPathSeparator(value: string): boolean { + return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value); +} + +export function isFilesystemBrowseQuery( + value: string, + platform = typeof navigator === "undefined" ? "" : navigator.platform, +): boolean { + const allowWindowsPaths = isWindowsPlatform(platform); + return ( + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || + value.startsWith("/") || + value === "~" || + value.startsWith("~/") || + value.startsWith("~\\") || + (allowWindowsPaths && isWindowsAbsolutePath(value)) + ); +} + +export function normalizeProjectPathForDispatch(value: string): string { + return trimTrailingPathSeparators(value.trim()); +} + +export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string { + const trimmedValue = value.trim(); + if (!isExplicitRelativePath(trimmedValue) || !cwd) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd)); + if (!absoluteBase) { + return normalizeProjectPathForDispatch(trimmedValue); + } + + const nextSegments = [...absoluteBase.segments]; + for (const segment of trimmedValue.split(/[\\/]+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + nextSegments.pop(); + continue; + } + nextSegments.push(segment); + } + + const joinedPath = nextSegments.join(absoluteBase.separator); + if (joinedPath.length === 0) { + return normalizeProjectPathForDispatch(absoluteBase.root); + } + + return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`); +} + +export function normalizeProjectPathForComparison(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) { + return normalized.replaceAll("/", "\\").toLowerCase(); + } + return normalized; +} + +export function findProjectByPath( + projects: ReadonlyArray, + candidatePath: string, +): T | undefined { + const normalizedCandidate = normalizeProjectPathForComparison(candidatePath); + if (normalizedCandidate.length === 0) { + return undefined; + } + + return projects.find( + (project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate, + ); +} + +export function inferProjectTitleFromPath(value: string): string { + const normalized = normalizeProjectPathForDispatch(value); + const absolutePath = splitAbsolutePath(normalized); + if (absolutePath) { + return absolutePath.segments.findLast(Boolean) ?? normalized; + } + + const segments = normalized.split(/[/\\]/); + return segments.findLast(Boolean) ?? normalized; +} + +export function appendBrowsePathSegment(currentPath: string, segment: string): string { + const separator = preferredPathSeparator(currentPath); + return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`; +} + +export function getBrowseDirectoryPath(currentPath: string): string { + if (hasTrailingPathSeparator(currentPath)) { + return currentPath; + } + + const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath); + if (lastSeparatorIndex < 0) { + return currentPath; + } + + return currentPath.slice(0, lastSeparatorIndex + 1); +} + +export function ensureBrowseDirectoryPath(currentPath: string): string { + const trimmed = currentPath.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + if (hasTrailingPathSeparator(trimmed)) { + return trimmed; + } + + return `${trimmed}${preferredPathSeparator(trimmed)}`; +} + +export function getBrowseParentPath(currentPath: string): string | null { + const trimmed = trimTrailingPathSeparators(currentPath); + const absolutePath = splitAbsolutePath(trimmed); + if (absolutePath) { + if (absolutePath.segments.length === 0) { + return null; + } + + if (absolutePath.segments.length === 1) { + return absolutePath.root; + } + + const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator); + return `${absolutePath.root}${parentSegments}${absolutePath.separator}`; + } + + const separator = preferredPathSeparator(currentPath); + const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed); + if (lastSeparatorIndex < 0) { + return null; + } + + if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) { + return `${trimmed.slice(0, 2)}${separator}`; + } + + return trimmed.slice(0, lastSeparatorIndex + 1); +} + +export function canNavigateUp(currentPath: string): boolean { + return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; +} diff --git a/apps/web/src/modelPickerOpenState.ts b/apps/web/src/modelPickerOpenState.ts new file mode 100644 index 00000000..5a4993c1 --- /dev/null +++ b/apps/web/src/modelPickerOpenState.ts @@ -0,0 +1,17 @@ +import { create } from "zustand"; + +const useModelPickerOpenStore = create<{ + open: boolean; + setOpen: (open: boolean) => void; +}>((set) => ({ + open: false, + setOpen: (open) => set((current) => (current.open === open ? current : { open })), +})); + +export function useModelPickerOpen(): boolean { + return useModelPickerOpenStore((store) => store.open); +} + +export function setModelPickerOpen(open: boolean): void { + useModelPickerOpenStore.getState().setOpen(open); +} diff --git a/apps/web/src/shortcutModifierState.ts b/apps/web/src/shortcutModifierState.ts new file mode 100644 index 00000000..3a9c1859 --- /dev/null +++ b/apps/web/src/shortcutModifierState.ts @@ -0,0 +1,88 @@ +import { create } from "zustand"; + +export interface ShortcutModifierState { + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +const EMPTY_SHORTCUT_MODIFIER_STATE: ShortcutModifierState = { + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, +}; + +function areShortcutModifierStatesEqual( + left: ShortcutModifierState, + right: ShortcutModifierState, +): boolean { + return ( + left.metaKey === right.metaKey && + left.ctrlKey === right.ctrlKey && + left.altKey === right.altKey && + left.shiftKey === right.shiftKey + ); +} + +const useShortcutModifierStateStore = create<{ + state: ShortcutModifierState; + setState: (state: ShortcutModifierState) => void; + clear: () => void; +}>((set) => ({ + state: EMPTY_SHORTCUT_MODIFIER_STATE, + setState: (state) => + set((current) => (areShortcutModifierStatesEqual(current.state, state) ? current : { state })), + clear: () => + set((current) => + areShortcutModifierStatesEqual(current.state, EMPTY_SHORTCUT_MODIFIER_STATE) + ? current + : { state: EMPTY_SHORTCUT_MODIFIER_STATE }, + ), +})); + +export function useShortcutModifierState(): ShortcutModifierState { + return useShortcutModifierStateStore((store) => store.state); +} + +function normalizeModifierKey(key: string): keyof ShortcutModifierState | null { + switch (key) { + case "Meta": + case "OS": + case "Command": + return "metaKey"; + case "Control": + return "ctrlKey"; + case "Alt": + case "Option": + return "altKey"; + case "Shift": + return "shiftKey"; + default: + return null; + } +} + +export function syncShortcutModifierStateFromKeyboardEvent(event: KeyboardEvent): void { + const normalizedModifierKey = normalizeModifierKey(event.key); + if (normalizedModifierKey) { + const currentState = useShortcutModifierStateStore.getState().state; + useShortcutModifierStateStore.getState().setState({ + ...currentState, + [normalizedModifierKey]: event.type === "keydown", + }); + return; + } + + useShortcutModifierStateStore.getState().setState({ + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + }); +} + +export function clearShortcutModifierState(): void { + useShortcutModifierStateStore.getState().clear(); +} diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 7c082f1f..81d2e6c2 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -59,6 +59,9 @@ const { searchEntries: vi.fn(), writeFile: vi.fn(), }, + filesystem: { + browse: vi.fn(), + }, shell: { openInEditor: vi.fn(), }, @@ -396,6 +399,21 @@ describe("wsNativeApi", () => { }); }); + it("forwards filesystem browse requests to the RPC client", async () => { + rpcClientMock.filesystem.browse.mockResolvedValue({ + parentPath: "/tmp", + entries: [{ name: "project", fullPath: "/tmp/project" }], + }); + + const api = createWsNativeApi(); + await expect(api.filesystem.browse({ partialPath: "/tmp/" })).resolves.toEqual({ + parentPath: "/tmp", + entries: [{ name: "project", fullPath: "/tmp/project" }], + }); + + expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ partialPath: "/tmp/" }); + }); + it("forwards full-thread diff requests to the orchestration RPC", async () => { rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 0737f71e..a20aee7c 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -55,6 +55,9 @@ export function createWsNativeApi(): NativeApi { searchEntries: rpcClient.projects.searchEntries, writeFile: rpcClient.projects.writeFile, }, + filesystem: { + browse: rpcClient.filesystem.browse, + }, shell: { openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), openExternal: async (url) => { diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index d1933ef4..e52a8bca 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -60,6 +60,9 @@ export interface WsRpcClient { readonly searchEntries: RpcUnaryMethod; readonly writeFile: RpcUnaryMethod; }; + readonly filesystem: { + readonly browse: RpcUnaryMethod; + }; readonly shell: { readonly openInEditor: (input: { readonly cwd: Parameters[0]; @@ -156,6 +159,9 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { writeFile: (input) => transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), }, + filesystem: { + browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), + }, shell: { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), diff --git a/bun.lock b/bun.lock index a65e3499..ea851511 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@t3tools/monorepo", "devDependencies": { + "@turbo/windows-64": "2.9.6", "@types/node": "catalog:", "node-gyp": "^11.2.0", "oxfmt": "^0.40.0", diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md new file mode 100644 index 00000000..36298f8e --- /dev/null +++ b/docs/upstream-sync-log.md @@ -0,0 +1,130 @@ +# Upstream Sync Log + +## 2026-04-27 + +- Fork branch: `sync/upstream-2026-04-27` +- Fork base: `boggedbrush/KodoCode@f46ba8d06b6a8fe3f4add376bc7ed46137168292` +- Upstream range reviewed: `pingdotgg/t3code@ada410bccff144ce4cfed0e2c6e18974b045f968..5cf83ffe8f9d5eabd1c17721bd1f9597c97d98fe` +- Upstream release window: `v0.0.22-nightly.20260427.135` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/15 + +### Classification + +- `5cf83ffe` `fix(release): use configured node for smoke manifest merge (#2364)` - `SKIP`: release-smoke tooling fix for an upstream Windows updater-manifest merge block that is not present in Kodo's current `scripts/release-smoke.ts`. Cherry-pick conflicts because the fixed invocation has no local equivalent to patch, and importing the surrounding upstream release-smoke coverage would be broader than this bounded sync. + +### Applied Changes + +- None. + +### Adapted Changes + +- None. + +### Selective Frontend Changes Ported + +- None. + +### Manual-Review Candidates + +- Carry forward the 2026-04-24 manual-review candidates: provider model-selection option arrays, dynamic tool-call request-permission schema, and Claude session cwd resume drift. + +### Skipped Changes + +- `5cf83ffe` release-smoke configured-Node fix: not applicable without the upstream Windows updater-manifest merge block. + +### Checks + +- `bun fmt` passed. +- `bun lint` passed with two existing warnings in `apps/server/src/os-jank.ts` and `apps/server/src/persistence/Layers/ProjectionThreads.ts`. +- `bun typecheck` passed. + +## 2026-04-25 + +- Fork branch: `sync/upstream-2026-04-25` +- Fork base: `boggedbrush/KodoCode@f46ba8d06b6a8fe3f4add376bc7ed46137168292` +- Upstream range reviewed: `pingdotgg/t3code@ada410bccff144ce4cfed0e2c6e18974b045f968..ada410bccff144ce4cfed0e2c6e18974b045f968` +- Upstream release window: no new upstream `main` commits after the 2026-04-24 reviewed endpoint. +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/14 + +### Classification + +- No new upstream commits to classify. The latest fetched upstream `main` remains at `ada410bccff144ce4cfed0e2c6e18974b045f968`, which was already reviewed in the 2026-04-24 sync entry. + +### Applied changes + +- None. + +### Adapted changes + +- None. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- Carry forward the 2026-04-24 manual-review candidates: provider model-selection option arrays, dynamic tool-call request-permission schema, and Claude session cwd resume drift. + +### Skipped changes + +- None newly skipped in this run. + +### Checks + +- `bun fmt` passed. +- `bun lint` passed with two existing warnings. +- `bun typecheck` passed. + +## 2026-04-24 + +- Fork branch: `sync/upstream-2026-04-24` +- Fork base: `boggedbrush/KodoCode@40cd204092a3de25d5592de74cf99c689d8fa6d7` +- Upstream range reviewed: `pingdotgg/t3code@b0b7b38da1dc4b19833d13f84eb907b1e2adfb63..ada410bccff144ce4cfed0e2c6e18974b045f968` +- Upstream release window: `v0.0.21-nightly.20260423.101..v0.0.21/main@2026-04-23` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/13 + +### Classification + +- `8d1d699f` `Refactor provider model selections to option arrays (#2246)` — `MANUAL REVIEW`: broad provider model-selection schema, settings, migration, server, shared, and web rewrite. It includes a new model-selection migration and visible composer/settings behavior, so it needs a dedicated adaptation pass rather than this bounded sync. +- `d5b7690f` `Exclude subscribe RPCs from latency tracking (#2313)` — `SKIP`: web/runtime latency-state behavior only; preserve Kodo-owned frontend behavior in this backend/runtime sync. +- `0ee302e2` `fix(request-permission): add dynamic_tool_call to command request (#2311)` — `MANUAL REVIEW`: protocol/contract request-permission shape change tied to runtime event semantics. It should be reviewed with the Codex app-server protocol surface before import. +- `0d55a428` `fix(web): ignore stale runtime projection snapshots (#2301)` — `SKIP`: frontend runtime projection handling; keep Kodo-specific session UX untouched in this pass. +- `188df6da` `Fix Claude session cwd resume drift (#2292)` — `MANUAL REVIEW`: valuable backend/runtime fix, but it touches Claude provider resume behavior and adapter tests. Defer for a focused provider-session adaptation with local validation. +- `00b5c3e1` `Add task sidebar auto-open setting (#2314)` — `SKIP`: user-visible settings/sidebar UX. +- `ada410bc` `chore(release): prepare v0.0.21` — `SKIP`: release/version churn only. + +### Applied changes + +- None. No reviewed upstream commit was safe to cherry-pick directly without importing broad provider schema churn or Kodo-owned frontend/settings behavior. + +### Adapted changes + +- None. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- `8d1d699f` Provider model selections to option arrays: dedicated schema/settings migration review required. +- `0ee302e2` Dynamic tool-call request-permission schema: protocol compatibility review required. +- `188df6da` Claude session cwd resume drift: focused provider-runtime adaptation and tests required. + +### Skipped changes + +- `d5b7690f` Subscribe RPC latency tracking: web runtime-state behavior. +- `0d55a428` Stale runtime projection snapshots: web runtime projection behavior. +- `00b5c3e1` Task sidebar auto-open setting: settings/sidebar UX. +- `ada410bc` Release prep: version churn. + +### Checks + +- `bun fmt` not run: this run only updated the sync log through the GitHub connector, and the local shell cannot fetch/write Git metadata due repository ACL restrictions. +- `bun lint` not run: same environment restriction. +- `bun typecheck` not run: same environment restriction. + +## Prior Sync History + +Detailed entries for 2026-04-17 through 2026-04-23 are preserved in the previous sync branch history and PR discussion at https://github.com/boggedbrush/KodoCode/pull/7. This run moved the active sync branch to `sync/upstream-2026-04-24` and recorded the current upstream review window here. diff --git a/package.json b/package.json index 00fb4951..8ade8eae 100644 --- a/package.json +++ b/package.json @@ -31,18 +31,18 @@ "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", - "start": "turbo run start --filter=kodo", - "start:desktop": "turbo run start --filter=@t3tools/desktop", + "start": "node scripts/run-local-turbo.mjs run start --filter=kodo", + "start:desktop": "node scripts/run-local-turbo.mjs run start --filter=@t3tools/desktop", "start:mock-update-server": "bun run scripts/mock-update-server.ts", - "build": "turbo run build", - "build:desktop": "turbo run build --filter=@t3tools/desktop --filter=kodo", - "typecheck": "turbo run typecheck", + "build": "node scripts/run-local-turbo.mjs run build", + "build:desktop": "node scripts/run-local-turbo.mjs run build --filter=@t3tools/desktop --filter=kodo", + "typecheck": "node scripts/typecheck-all.mjs", "lint": "oxlint --report-unused-disable-directives", - "test": "turbo run test", - "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", + "test": "node scripts/run-local-turbo.mjs run test", + "test:desktop-smoke": "node scripts/run-local-turbo.mjs run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "fmt:check": "oxfmt --check", - "build:contracts": "turbo run build --filter=@t3tools/contracts", + "build:contracts": "node scripts/run-local-turbo.mjs run build --filter=@t3tools/contracts", "dist:desktop:artifact": "node scripts/build-desktop-artifact.ts", "dist:desktop:dmg": "node scripts/build-desktop-artifact.ts --platform mac --target dmg", "dist:desktop:dmg:arm64": "node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64", @@ -54,6 +54,7 @@ "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, "devDependencies": { + "@turbo/windows-64": "2.9.6", "@types/node": "catalog:", "node-gyp": "^11.2.0", "oxfmt": "^0.40.0", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index b1937018..8bd1570d 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -22,8 +22,8 @@ } }, "scripts": { - "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean", - "build": "tsdown src/index.ts --format esm,cjs --dts --clean", + "dev": "tsdown src/index.ts --format esm,cjs --watch --clean", + "build": "tsdown src/index.ts --format esm,cjs --clean", "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", "test": "vitest run" diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 2ffdf4c6..569f096d 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -8,12 +8,14 @@ type EditorDefinition = { readonly id: string; readonly label: string; readonly commands: readonly [string, ...string[]] | null; + readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; }; export const EDITORS = [ { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, + { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, { id: "vscode-insiders", diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts new file mode 100644 index 00000000..9878fd1a --- /dev/null +++ b/packages/contracts/src/filesystem.ts @@ -0,0 +1,31 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const FILESYSTEM_PATH_MAX_LENGTH = 512; + +export const FilesystemBrowseInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemBrowseInput = typeof FilesystemBrowseInput.Type; + +export const FilesystemBrowseEntry = Schema.Struct({ + name: TrimmedNonEmptyString, + fullPath: TrimmedNonEmptyString, +}); +export type FilesystemBrowseEntry = typeof FilesystemBrowseEntry.Type; + +export const FilesystemBrowseResult = Schema.Struct({ + parentPath: TrimmedNonEmptyString, + entries: Schema.Array(FilesystemBrowseEntry), +}); +export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; + +export class FilesystemBrowseError extends Schema.TaggedErrorClass()( + "FilesystemBrowseError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5db7fe34..5629f91d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,7 @@ export * from "./keybindings"; export * from "./server"; export * from "./settings"; export * from "./enhance"; +export * from "./filesystem"; export * from "./git"; export * from "./orchestration"; export * from "./editor"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 914817a0..d8cceac7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,6 +24,7 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; +import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import type { ServerConfig, ServerProviderUpdatedPayload, @@ -159,6 +160,9 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; + filesystem: { + browse: (input: FilesystemBrowseInput) => Promise; + }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 942cc11f..035fcfec 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -59,6 +59,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedReviewMode.command, "composer.mode.review"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { + key: "mod+shift+m", + command: "modelPicker.toggle", + }); + assert.strictEqual(parsedModelPickerToggle.command, "modelPicker.toggle"); + + const parsedModelPickerJump = yield* decode(KeybindingRule, { + key: "mod+1", + command: "modelPicker.jump.1", + }); + assert.strictEqual(parsedModelPickerJump.command, "modelPicker.jump.1"); + const parsedThreadPrevious = yield* decode(KeybindingRule, { key: "mod+shift+[", command: "thread.previous", @@ -155,8 +167,23 @@ it.effect("parses resolved keybindings arrays", () => modKey: true, }, }, + { + command: "modelPicker.jump.3", + shortcut: { + key: "3", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "identifier", + name: "modelPickerOpen", + }, + }, ]); - assert.lengthOf(parsed, 2); + assert.lengthOf(parsed, 3); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 13426ea3..e1800214 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -20,6 +20,20 @@ export const THREAD_JUMP_KEYBINDING_COMMANDS = [ ] as const; export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; +export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [ + "modelPicker.jump.1", + "modelPicker.jump.2", + "modelPicker.jump.3", + "modelPicker.jump.4", + "modelPicker.jump.5", + "modelPicker.jump.6", + "modelPicker.jump.7", + "modelPicker.jump.8", + "modelPicker.jump.9", +] as const; +export type ModelPickerJumpKeybindingCommand = + (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number]; + export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", @@ -27,6 +41,12 @@ export const THREAD_KEYBINDING_COMMANDS = [ ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; +export const MODEL_PICKER_KEYBINDING_COMMANDS = [ + "modelPicker.toggle", + ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, +] as const; +export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; + const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", @@ -42,6 +62,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "composer.mode.review", "app.reload", "editor.openFavorite", + ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, ] as const; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 65bcc7ce..97427cf9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -4,6 +4,7 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import { OpenError, OpenInEditorInput } from "./editor"; import { PromptEnhanceInput, PromptEnhanceResult } from "./enhance"; +import { FilesystemBrowseError, FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem"; import { GitActionProgressEvent, GitCheckoutInput, @@ -90,6 +91,9 @@ export const WS_METHODS = { // Shell methods shellOpenInEditor: "shell.openInEditor", + // Filesystem methods + filesystemBrowse: "filesystem.browse", + // Git methods gitPull: "git.pull", gitRefreshStatus: "git.refreshStatus", @@ -191,6 +195,12 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); +export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { + payload: FilesystemBrowseInput, + success: FilesystemBrowseResult, + error: FilesystemBrowseError, +}); + export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, success: GitStatusStreamEvent, @@ -377,6 +387,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, + WsFilesystemBrowseRpc, WsSubscribeGitStatusRpc, WsGitPullRpc, WsGitRefreshStatusRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90be6c79..3a72da54 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -21,6 +21,16 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const ProjectPickerMode = Schema.Literals(["fullscreen", "sidebar"]); +export type ProjectPickerMode = typeof ProjectPickerMode.Type; +export const DEFAULT_PROJECT_PICKER_MODE: ProjectPickerMode = "fullscreen"; + +export const FavoriteModelSchema = Schema.Struct({ + provider: ProviderKind, + model: TrimmedNonEmptyString, +}); +export type FavoriteModel = typeof FavoriteModelSchema.Type; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -33,6 +43,10 @@ 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)), + favorites: Schema.Array(FavoriteModelSchema).pipe(Schema.withDecodingDefault(() => [])), + projectPickerMode: ProjectPickerMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROJECT_PICKER_MODE), + ), promptEnhancePreset: PromptEnhancePreset.pipe( Schema.withDecodingDefault(() => "balanced" as const satisfies typeof PromptEnhancePreset.Type), ), diff --git a/packages/contracts/tsdown.config.ts b/packages/contracts/tsdown.config.ts new file mode 100644 index 00000000..52e04375 --- /dev/null +++ b/packages/contracts/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + outDir: "dist", + clean: true, + dts: false, +}); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e2393eef..1c6494a5 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import { extractPathFromShellOutput, + listLoginShellCandidates, + mergePathEntries, readEnvironmentFromLoginShell, + readPathFromLaunchctl, readPathFromLoginShell, } from "./shell"; @@ -60,6 +63,38 @@ describe("readPathFromLoginShell", () => { }); }); +describe("readPathFromLaunchctl", () => { + it("returns a trimmed PATH value from launchctl", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => " /opt/homebrew/bin:/usr/bin \n"); + + expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin"); + expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }); + }); + + it("returns undefined when launchctl is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => { + throw new Error("spawn /bin/launchctl ENOENT"); + }); + + expect(readPathFromLaunchctl(execFile)).toBeUndefined(); + }); +}); + describe("readEnvironmentFromLoginShell", () => { it("extracts multiple environment variables from a login shell command", () => { const execFile = vi.fn< @@ -126,3 +161,30 @@ describe("readEnvironmentFromLoginShell", () => { }); }); }); + +describe("listLoginShellCandidates", () => { + it("returns env shell, user shell, then the platform fallback without duplicates", () => { + expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([ + "/opt/homebrew/bin/nu", + "/bin/zsh", + ]); + }); + + it("falls back to the platform default when no shells are available", () => { + expect(listLoginShellCandidates("linux", undefined, "")).toEqual(["/bin/bash"]); + }); +}); + +describe("mergePathEntries", () => { + it("prefers login-shell PATH entries and keeps inherited extras", () => { + expect( + mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"), + ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + }); + + it("uses the platform-specific delimiter", () => { + expect(mergePathEntries("C:\\Tools;C:\\Windows", "C:\\Windows;C:\\Git", "win32")).toBe( + "C:\\Tools;C:\\Windows;C:\\Git", + ); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index d9e8a788..9cd20688 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,3 +1,4 @@ +import * as OS from "node:os"; import { execFileSync } from "node:child_process"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; @@ -10,24 +11,38 @@ 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; - } +function trimNonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} - if (platform === "darwin") { - return "/bin/zsh"; +function readUserLoginShell(): string | undefined { + try { + return trimNonEmpty(OS.userInfo().shell); + } catch { + return undefined; } +} - if (platform === "linux") { - return "/bin/bash"; +export function listLoginShellCandidates( + platform: NodeJS.Platform, + shell: string | undefined, + userShell = readUserLoginShell(), +): ReadonlyArray { + const fallbackShell = + platform === "darwin" ? "/bin/zsh" : platform === "linux" ? "/bin/bash" : undefined; + const seen = new Set(); + const candidates: string[] = []; + + for (const candidate of [trimNonEmpty(shell), trimNonEmpty(userShell), fallbackShell]) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + candidates.push(candidate); } - return undefined; + return candidates; } export function extractPathFromShellOutput(output: string): string | null { @@ -49,6 +64,45 @@ export function readPathFromLoginShell( return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH; } +export function readPathFromLaunchctl( + execFile: ExecFileSyncLike = execFileSync, +): string | undefined { + try { + return trimNonEmpty( + execFile("/bin/launchctl", ["getenv", "PATH"], { + encoding: "utf8", + timeout: 2000, + }), + ); + } catch { + return undefined; + } +} + +export function mergePathEntries( + preferredPath: string | undefined, + inheritedPath: string | undefined, + platform: NodeJS.Platform, +): string | undefined { + const delimiter = platform === "win32" ? ";" : ":"; + const merged: string[] = []; + const seen = new Set(); + + for (const pathValue of [preferredPath, inheritedPath]) { + if (!pathValue) continue; + for (const entry of pathValue.split(delimiter)) { + const trimmedEntry = entry.trim(); + if (!trimmedEntry || seen.has(trimmedEntry)) { + continue; + } + seen.add(trimmedEntry); + merged.push(trimmedEntry); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + function envCaptureStart(name: string): string { return `__T3CODE_ENV_${name}_START__`; } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index b1207f86..5f2d2996 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -90,9 +90,9 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); - assert.equal(env.T3CODE_NO_BROWSER, "1"); - assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); - assert.equal(env.T3CODE_LOG_WS_EVENTS, "1"); + assert.equal(env.T3CODE_NO_BROWSER, "true"); + assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "false"); + assert.equal(env.T3CODE_LOG_WS_EVENTS, "true"); assert.equal(env.T3CODE_HOST, "0.0.0.0"); assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); }), @@ -139,7 +139,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); + assert.equal(env.T3CODE_LOG_WS_EVENTS, "false"); }), ); @@ -172,7 +172,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { T3CODE_PORT: "3773", T3CODE_AUTH_TOKEN: "stale-token", T3CODE_MODE: "web", - T3CODE_NO_BROWSER: "0", + T3CODE_NO_BROWSER: "false", T3CODE_HOST: "0.0.0.0", VITE_WS_URL: "ws://localhost:3773", }, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 02df738d..cb9c9a3a 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -1,7 +1,10 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -16,6 +19,49 @@ const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; const DESKTOP_DEV_RUNNER_PID_ENV = "KODOCODE_DEV_RUNNER_PID"; const DESKTOP_DEV_SHUTDOWN_SIGNAL_ENV = "KODOCODE_DEV_SHUTDOWN_SIGNAL"; +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = dirname(SCRIPT_DIR); + +function encodeBooleanEnv(value: boolean): string { + return value ? "true" : "false"; +} + +function resolveTurboCommand(): string { + const candidatePaths = + process.platform === "win32" + ? [ + join(REPO_ROOT, "node_modules", ".bin", "turbo.exe"), + join(REPO_ROOT, "node_modules", ".bin", "turbo.cmd"), + ] + : [join(REPO_ROOT, "node_modules", ".bin", "turbo")]; + + return candidatePaths.find((candidate) => existsSync(candidate)) ?? "turbo"; +} + +function resolveBunCommand(): string { + const candidate = process.env.BUN_BINARY?.trim(); + if (candidate && existsSync(candidate)) { + return candidate; + } + + const candidatePaths = + process.platform === "win32" + ? [ + join(process.env.LOCALAPPDATA ?? "", "Microsoft", "WinGet", "Links", "bun.exe"), + join( + process.env.LOCALAPPDATA ?? "", + "Microsoft", + "WinGet", + "Packages", + "Oven-sh.Bun_Microsoft.Winget.Source_8wekyb3d8bbwe", + "bun-windows-x64", + "bun.exe", + ), + ] + : [join(process.env.HOME ?? "", ".bun", "bin", "bun")]; + + return candidatePaths.find((entry) => entry && existsSync(entry)) ?? "bun"; +} export const DESKTOP_DEV_SHUTDOWN_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGTERM" : "SIGUSR2"; @@ -191,19 +237,19 @@ export function createDevRunnerEnv({ } if (!isDesktopMode && noBrowser !== undefined) { - output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; + output.T3CODE_NO_BROWSER = encodeBooleanEnv(noBrowser); } else if (!isDesktopMode) { delete output.T3CODE_NO_BROWSER; } if (autoBootstrapProjectFromCwd !== undefined) { - output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; + output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = encodeBooleanEnv(autoBootstrapProjectFromCwd); } else { delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; } if (logWebSocketEvents !== undefined) { - output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; + output.T3CODE_LOG_WS_EVENTS = encodeBooleanEnv(logWebSocketEvents); } else { delete output.T3CODE_LOG_WS_EVENTS; } @@ -568,24 +614,46 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { return; } - const child = yield* ChildProcess.make( - "turbo", - [...MODE_ARGS[input.mode], ...input.turboArgs], - { + const command = input.mode === "dev:server" ? resolveBunCommand() : resolveTurboCommand(); + const args = + input.mode === "dev:server" + ? ["run", "src/bin.ts"] + : [...MODE_ARGS[input.mode], ...input.turboArgs]; + const cwd = input.mode === "dev:server" ? join(REPO_ROOT, "apps", "server") : undefined; + + if (input.mode === "dev:server") { + const contractsBuild = yield* ChildProcess.make(command, ["run", "build"], { + cwd: join(REPO_ROOT, "packages", "contracts"), stdin: "inherit", stdout: "inherit", stderr: "inherit", env, extendEnv: false, - // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). - shell: process.platform === "win32", - // Keep turbo in the same process group so terminal signals (Ctrl+C) - // reach it directly. Effect defaults to detached: true on non-Windows, - // which would put turbo in a new group and require manual forwarding. + shell: process.platform === "win32" && !command.includes("\\"), detached: false, forceKillAfter: "1500 millis", - }, - ); + }); + const buildExitCode = yield* contractsBuild.exitCode; + if (buildExitCode !== 0) { + return yield* new DevRunnerError({ + message: `contracts build exited with code ${buildExitCode}`, + }); + } + } + + const child = yield* ChildProcess.make(command, args, { + cwd, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + extendEnv: false, + shell: process.platform === "win32" && !command.includes("\\"), + // Keep the child in the same process group so terminal signals (Ctrl+C) + // reach it directly. + detached: false, + forceKillAfter: "1500 millis", + }); const childPid = Number(child.pid); const signalHandlers: Array void]> = []; @@ -626,7 +694,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const exitCode = yield* child.exitCode; if (exitCode !== 0) { return yield* new DevRunnerError({ - message: `turbo exited with code ${exitCode}`, + message: `${input.mode === "dev:server" ? "bun" : "turbo"} exited with code ${exitCode}`, }); } } finally { diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index 57dab49f..0d1a22cd 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -1,5 +1,6 @@ import { resolve, relative } from "node:path"; import { realpathSync } from "node:fs"; +import { file, serve } from "bun"; const port = Number(process.env.T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT ?? 3000); const root = @@ -19,10 +20,10 @@ function isWithinRoot(filePath: string): boolean { } } -Bun.serve({ +serve({ port, hostname: "localhost", - fetch: async (request) => { + fetch: async (request: { url: string }) => { const url = new URL(request.url); const path = url.pathname; mockServerLog("info", `Request received for path: ${path}`); @@ -31,13 +32,13 @@ Bun.serve({ 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())) { + const requestedFile = file(filePath); + if (!(await requestedFile.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()); + return new Response(requestedFile); }, }); diff --git a/scripts/run-local-turbo.mjs b/scripts/run-local-turbo.mjs new file mode 100644 index 00000000..38f642a9 --- /dev/null +++ b/scripts/run-local-turbo.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = dirname(scriptDir); +const candidatePaths = + process.platform === "win32" + ? [ + join(repoRoot, "node_modules", ".bin", "turbo.exe"), + join(repoRoot, "node_modules", ".bin", "turbo.cmd"), + ] + : [join(repoRoot, "node_modules", ".bin", "turbo")]; + +const turboPath = candidatePaths.find((candidate) => existsSync(candidate)); + +if (!turboPath) { + console.error("Unable to find local turbo binary in node_modules/.bin"); + process.exit(1); +} + +const result = spawnSync(turboPath, process.argv.slice(2), { + stdio: "inherit", + cwd: repoRoot, + windowsHide: false, +}); + +if (result.error) { + throw result.error; +} + +if (result.status !== null) { + process.exit(result.status); +} + +process.kill(process.pid, result.signal ?? "SIGTERM"); diff --git a/scripts/typecheck-all.mjs b/scripts/typecheck-all.mjs new file mode 100644 index 00000000..936143eb --- /dev/null +++ b/scripts/typecheck-all.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +const workspaces = [ + "packages/contracts", + "packages/shared", + "apps/web", + "apps/server", + "apps/desktop", + "scripts", +]; + +const bunCommand = process.env.BUN_BINARY ?? "bun"; + +for (const workspace of workspaces) { + const result = spawnSync(bunCommand, ["run", "typecheck"], { + cwd: new URL(`../${workspace}/`, import.meta.url), + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, + windowsHide: false, + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/turbo.json b/turbo.json index e6504927..0db5cef3 100644 --- a/turbo.json +++ b/turbo.json @@ -23,7 +23,7 @@ "T3CODE_OTLP_EXPORT_INTERVAL_MS", "T3CODE_OTLP_SERVICE_NAME" ], - "globalPassThroughEnv": ["KODOCODE_DEV_RUNNER_PID", "KODOCODE_DEV_SHUTDOWN_SIGNAL"], + "globalPassThroughEnv": ["KODOCODE_DEV_RUNNER_PID", "KODOCODE_DEV_SHUTDOWN_SIGNAL", "PATHEXT"], "tasks": { "build": { "dependsOn": ["^build"],