From dc38f95443f4f6feae4570cb547673deb3da7f2f Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 21:56:59 -0400 Subject: [PATCH 01/36] [sync] Remove Claude subscription-based model adjustment (#1899) Upstream-Ref: pingdotgg/t3code@678f827 --- .../src/provider/Layers/ClaudeProvider.ts | 55 +------------------ 1 file changed, 3 insertions(+), 52 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d95a4d76..d11f7cc7 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -276,18 +276,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,41 +336,6 @@ 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; @@ -563,8 +516,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 +524,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, probe: { installed: true, version: parsedVersion, @@ -592,7 +543,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, probe: { installed: true, version: parsedVersion, @@ -609,7 +560,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models: resolvedModels, + models, probe: { installed: true, version: parsedVersion, From 488fdefd7c124573783a690703ab5b02d4611f87 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 21:58:45 -0400 Subject: [PATCH 02/36] [sync] Handle deleted git directories as non-repositories (#1907) Upstream-Ref: pingdotgg/t3code@e0e01b4 --- apps/server/src/git/Layers/GitCore.test.ts | 64 ++++++ apps/server/src/git/Layers/GitCore.ts | 44 ++++- apps/server/src/git/Layers/GitHubCli.test.ts | 99 ++++++++++ apps/server/src/git/Layers/GitHubCli.ts | 118 ++++------- apps/server/src/git/Layers/GitManager.test.ts | 185 ++++++++++++++++++ apps/server/src/git/Layers/GitManager.ts | 118 +++-------- apps/server/src/git/githubPullRequests.ts | 128 ++++++++++++ 7 files changed, 589 insertions(+), 167 deletions(-) create mode 100644 apps/server/src/git/githubPullRequests.ts diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 2da511f5..e360b924 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -41,6 +41,24 @@ function writeTextFile( }); } +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 }); + }); +} + /** 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(); @@ -1631,6 +1664,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..546071bc 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,6 +61,18 @@ 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; @@ -374,6 +387,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, @@ -1228,7 +1251,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 +1387,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 +1787,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..b5bb89e4 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, SchemaIssue } 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); +} From 73eafb5e4eae0f6d6b88ec734865029377f1acbd Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:00:05 -0400 Subject: [PATCH 03/36] [sync-adapted] Coalesce status refreshes by remote (#1940) Upstream-Ref: pingdotgg/t3code@f9019cd Adapted-by: kept Kodo non-interactive fetch hardening while adopting per-remote refresh coalescing --- apps/server/src/git/Layers/GitCore.test.ts | 141 ++++++++++++--------- apps/server/src/git/Layers/GitCore.ts | 32 ++--- 2 files changed, 93 insertions(+), 80 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index e360b924..43963026 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -831,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("coalesces upstream refreshes across sibling worktrees on the same remote", () => Effect.gen(function* () { const ok = (stdout = "") => Effect.succeed({ @@ -850,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"); @@ -861,10 +863,22 @@ it.layer(TestLayer)("git integration", (it) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { fetchCount += 1; expect(input.cwd).toBe("/repo"); + expect(input.args).toEqual([ + "--git-dir", + "/repo/.git", + "fetch", + "--quiet", + "--no-tags", + "origin", + ]); 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" || @@ -891,70 +905,80 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); + it.effect( + "briefly backs off failed upstream refreshes across sibling worktrees on one remote", + () => + Effect.gen(function* () { + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok("origin/main\n"); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; + let fetchCount = 0; + const core = yield* makeIsolatedGitCore((input) => { + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + 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"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "simulated fetch timeout", + }), + ); + } + if (input.operation === "GitCore.statusDetails.status") { + 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" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } return Effect.fail( new GitCommandError({ operation: input.operation, command: `git ${input.args.join(" ")}`, cwd: input.cwd, - detail: "simulated fetch timeout", + detail: "Unexpected git command in refresh failure cooldown test.", }), ); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", - }), - ); - }); + }); - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(1); + }), ); it.effect("throws when branch does not exist", () => @@ -1052,7 +1076,6 @@ it.layer(TestLayer)("git integration", (it) => { "--quiet", "--no-tags", remoteName, - `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, ]); }), ); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 546071bc..834e1b59 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -79,11 +79,9 @@ type TraceTailState = { remainder: string; }; -class StatusUpstreamRefreshCacheKey extends Data.Class<{ +class StatusRemoteRefreshCacheKey extends Data.Class<{ gitCommonDir: string; - upstreamRef: string; remoteName: string; - upstreamBranch: string; }> {} interface ExecuteGitOptions { @@ -949,17 +947,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); - const fetchUpstreamRefForStatus = ( + const fetchRemoteForStatus = ( gitCommonDir: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + remoteName: string, ): Effect.Effect => { - 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], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { allowNonZeroExit: true, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), @@ -976,20 +973,15 @@ 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, { - upstreamRef: cacheKey.upstreamRef, - remoteName: cacheKey.remoteName, - upstreamBranch: cacheKey.upstreamBranch, - }); + yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); return true as const; }); - const statusUpstreamRefreshCache = yield* Cache.makeWith({ + const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - lookup: refreshStatusUpstreamCacheEntry, // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. timeToLive: (exit) => Exit.isSuccess(exit) @@ -1010,12 +1002,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( - statusUpstreamRefreshCache, - new StatusUpstreamRefreshCacheKey({ + statusRemoteRefreshCache, + new StatusRemoteRefreshCacheKey({ gitCommonDir, - upstreamRef: upstream.upstreamRef, remoteName: upstream.remoteName, - upstreamBranch: upstream.upstreamBranch, }), ); }); From 88707cf04a086f8a18f5e6a1780d2148c7be489c Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:00:26 -0400 Subject: [PATCH 04/36] [sync] fix: quote editor launch args on Windows to support paths with spaces (#1805) Upstream-Ref: pingdotgg/t3code@2fce84a --- apps/server/src/open.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index ef50d3a5..25c2d6a8 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -293,11 +293,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 })), From 78be17a773bbf9768ab5220da798bee7182a1d4b Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:00:31 -0400 Subject: [PATCH 05/36] [sync] fix: Align token usage metrics for both Claude and Codex (#1943) Upstream-Ref: pingdotgg/t3code@7a00846 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 144 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 95 ++++++------ 2 files changed, 191 insertions(+), 48 deletions(-) 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..909d44f6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -17,6 +17,8 @@ import { type SDKResultMessage, type SettingSource, type SDKUserMessage, + ModelUsage, + NonNullableUsage, } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, @@ -274,24 +276,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 +291,58 @@ function maxClaudeContextWindowFromModelUsage(modelUsage: unknown): number | und } function normalizeClaudeTokenUsage( - usage: unknown, + value: NonNullableUsage | undefined, 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 +1327,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 +1338,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 +1351,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; From 53cad2dc6ec4e22e794675922c1fb8a8adeffa9e Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:01:19 -0400 Subject: [PATCH 06/36] [sync] fix: lost provider session recovery (#1938) Upstream-Ref: pingdotgg/t3code@d18e43b --- .../Layers/ProviderCommandReactor.test.ts | 55 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 4 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ab1dc3ed..fa947471 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1339,6 +1339,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.make("cmd-session-set-stale"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("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.make("cmd-turn-start-stale"), + threadId: ThreadId.make("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.make("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.make("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..b890e0dc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -303,14 +303,14 @@ 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" From dcc8bbf0919a0f77ed696592622fb740c88c0a29 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:02:01 -0400 Subject: [PATCH 07/36] [sync-adapted] Refresh git status after branch rename and worktree setup (#2005) Upstream-Ref: pingdotgg/t3code@9dcea68 Adapted-by: kept Kodo server test scaffolding while porting the runtime refresh fix --- .../Layers/ProviderCommandReactor.test.ts | 34 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 3 ++ apps/server/src/ws.ts | 1 + 3 files changed, 38 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fa947471..ddff7005 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 () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b890e0dc..7f833d9a 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({ @@ -473,6 +475,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/ws.ts b/apps/server/src/ws.ts index 25a65f72..58f4ef8e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -360,6 +360,7 @@ const makeWsRpcLayer = () => branch: worktree.worktree.branch, worktreePath: targetWorktreePath, }); + yield* refreshGitStatus(targetWorktreePath); } yield* runSetupProgram(); From 0808aba39a5a5547242357537db60639f22f85c3 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:03:07 -0400 Subject: [PATCH 08/36] [sync] Improve shell PATH hydration and fallback detection (#1799) Upstream-Ref: pingdotgg/t3code@2e42f3f --- apps/desktop/src/syncShellEnvironment.test.ts | 74 ++++++++++++++++- apps/desktop/src/syncShellEnvironment.ts | 67 +++++++++++++--- apps/server/src/os-jank.test.ts | 35 +++++++- apps/server/src/os-jank.ts | 45 +++++++++-- packages/shared/src/shell.test.ts | 62 ++++++++++++++ packages/shared/src/shell.ts | 80 ++++++++++++++++--- 6 files changed, 325 insertions(+), 38 deletions(-) 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/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..33b67128 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,28 +1,57 @@ 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"; + +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; + } + } + + const launchctlPath = + platform === "darwin" && !shellPath + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() + : undefined; + const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform); + if (mergedPath) { + env.PATH = mergedPath; } - } catch { - // Silently ignore — keep default PATH + } catch (error) { + logWarning("Failed to hydrate PATH from the user environment.", error); } } 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__`; } From 63ad15f93408c7d8036c18330e550c42ffad8afd Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:04:09 -0400 Subject: [PATCH 09/36] [sync-adapted] Backfill projected shell summaries and stale approval cleanup (#2004) Upstream-Ref: pingdotgg/t3code@c9b07d6 Adapted-by: merged new shell-summary projections into Kodo migration registry and pipeline state --- .../Layers/ProjectionPipeline.test.ts | 323 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 113 +++++- apps/server/src/persistence/Migrations.ts | 4 + ...ckfillProjectionThreadShellSummary.test.ts | 218 ++++++++++++ ...24_BackfillProjectionThreadShellSummary.ts | 277 +++++++++++++++ 5 files changed, 934 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts create mode 100644 apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 18507454..d4a3c87a 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.make("evt-stale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-stale-approval"), + occurredAt: "2026-02-26T12:30:00.000Z", + commandId: CommandId.make("cmd-stale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("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.make("evt-stale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:01.000Z", + commandId: CommandId.make("cmd-stale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + projectId: ProjectId.make("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.make("evt-stale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:02.000Z", + commandId: CommandId.make("cmd-stale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("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.make("evt-stale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-approval"), + occurredAt: "2026-02-26T12:30:03.000Z", + commandId: CommandId.make("cmd-stale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-approval"), + activity: { + id: EventId.make("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.make("evt-nonstale-approval-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-nonstale-approval"), + occurredAt: "2026-02-26T12:45:00.000Z", + commandId: CommandId.make("cmd-nonstale-approval-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("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.make("evt-nonstale-approval-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:01.000Z", + commandId: CommandId.make("cmd-nonstale-approval-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + projectId: ProjectId.make("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.make("evt-nonstale-approval-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:02.000Z", + commandId: CommandId.make("cmd-nonstale-approval-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("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.make("evt-nonstale-approval-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:03.000Z", + commandId: CommandId.make("cmd-nonstale-approval-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("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.make("turn-nonstale-failure"), + createdAt: "2026-02-26T12:45:03.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-nonstale-approval-5"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-nonstale-approval"), + occurredAt: "2026-02-26T12:45:04.000Z", + commandId: CommandId.make("cmd-nonstale-approval-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-nonstale-approval-5"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-nonstale-approval"), + activity: { + id: EventId.make("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..fcc291ae 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -2,6 +2,7 @@ import { ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, + ThreadId, } from "@t3tools/contracts"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -86,7 +87,89 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return null; } const requestId = (payload as Record).requestId; - return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; + return typeof requestId === "string" ? ApprovalRequestId.make(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 derivePendingUserInputCountFromActivities( + activities: ReadonlyArray, +): number { + const openRequestIds = new Set(); + const ordered = [...activities].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + + for (const activity of ordered) { + const requestId = extractActivityRequestId(activity.payload); + if (requestId === null) { + continue; + } + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + + if (activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + continue; + } + + if (activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + detail !== null && + (detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request")) + ) { + openRequestIds.delete(requestId); + } + } + + return openRequestIds.size; +} + +function deriveHasActionableProposedPlan(input: { + readonly latestTurnId: string | null; + readonly proposedPlans: ReadonlyArray; +}): boolean { + const sorted = [...input.proposedPlans].toSorted( + (left, right) => + left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), + ); + + let latestForTurn: ProjectionThreadProposedPlan | null = null; + if (input.latestTurnId !== null) { + for (let index = sorted.length - 1; index >= 0; index -= 1) { + const plan = sorted[index]; + if (plan?.turnId === input.latestTurnId) { + latestForTurn = plan; + break; + } + } + } + if (latestForTurn !== null) { + return latestForTurn.implementedAt === null; + } + + const latestPlan = sorted.at(-1) ?? null; + return latestPlan !== null && latestPlan.implementedAt === null; } function retainProjectionMessagesAfterRevert( @@ -1121,6 +1204,34 @@ 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; + } if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { return; } diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8c9fe4d9..01c649f7 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,6 +35,8 @@ 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"; /** * Migration loader with all migrations defined inline. @@ -69,6 +71,8 @@ export const migrationEntries = [ [20, "AuthAccessManagement", Migration0020], [21, "AuthSessionClientMetadata", Migration0021], [22, "AuthSessionLastConnectedAt", Migration0022], + [23, "ProjectionThreadShellSummary", Migration0023], + [24, "BackfillProjectionThreadShellSummary", Migration0024], ] as const; export const makeMigrationLoader = (throughId?: number) => 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) + `; +}); From a06b35c64e1f3dcfce772e6c91fa64f51533d928 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:06:25 -0400 Subject: [PATCH 10/36] [sync] fix(server): extend negative repository identity cache ttl (#2083) Upstream-Ref: pingdotgg/t3code@d90e15d --- .../src/project/Layers/RepositoryIdentityResolver.test.ts | 8 +++++--- .../src/project/Layers/RepositoryIdentityResolver.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) 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; From 15d217cc03adfdb5438b03a9993f9f8d8230ced7 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:08:32 -0400 Subject: [PATCH 11/36] [sync] feat: Add Kiro editor support to open picker (#1974) Upstream-Ref: pingdotgg/t3code@5f7becf --- apps/server/src/open.test.ts | 23 ++++++++- apps/server/src/open.ts | 10 +++- apps/web/src/components/ChatView.browser.tsx | 49 +++++++++++++++++++ apps/web/src/components/Icons.tsx | 18 +++++++ apps/web/src/components/chat/OpenInPicker.tsx | 6 +++ packages/contracts/src/editor.ts | 2 + 6 files changed, 106 insertions(+), 2 deletions(-) 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 25c2d6a8..f8df8953 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -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), }; } 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/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/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 Date: Fri, 17 Apr 2026 22:08:37 -0400 Subject: [PATCH 12/36] [sync] fix: prevent user-input activities from leaking into pending approvals projection (#2051) Upstream-Ref: pingdotgg/t3code@d22c6f5 --- .../server/src/orchestration/Layers/ProjectionPipeline.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fcc291ae..91ef837e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1232,6 +1232,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } 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; } From 6302167210e4f87bc948db4d62cc539eb253927b Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:19:17 -0400 Subject: [PATCH 13/36] chore(sync): add sync log and clean validation issues --- apps/server/src/git/Layers/GitHubCli.ts | 2 +- .../Layers/ProjectionPipeline.ts | 72 ------------------ docs/upstream-sync-log.md | 76 +++++++++++++++++++ scripts/mock-update-server.ts | 11 +-- 4 files changed, 83 insertions(+), 78 deletions(-) create mode 100644 docs/upstream-sync-log.md diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index b5bb89e4..75dfe0f3 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; +import { Effect, Layer, Result, Schema } from "effect"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { runProcess } from "../../processRunner"; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 91ef837e..5a743de6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -2,7 +2,6 @@ import { ApprovalRequestId, type ChatAttachment, type OrchestrationEvent, - ThreadId, } from "@t3tools/contracts"; import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -101,77 +100,6 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { ); } -function derivePendingUserInputCountFromActivities( - activities: ReadonlyArray, -): number { - const openRequestIds = new Set(); - const ordered = [...activities].toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || - left.activityId.localeCompare(right.activityId), - ); - - for (const activity of ordered) { - const requestId = extractActivityRequestId(activity.payload); - if (requestId === null) { - continue; - } - const payload = - typeof activity.payload === "object" && activity.payload !== null - ? (activity.payload as Record) - : null; - const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; - - if (activity.kind === "user-input.requested") { - openRequestIds.add(requestId); - continue; - } - - if (activity.kind === "user-input.resolved") { - openRequestIds.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.user-input.respond.failed" && - detail !== null && - (detail.includes("stale pending user-input request") || - detail.includes("unknown pending user-input request")) - ) { - openRequestIds.delete(requestId); - } - } - - return openRequestIds.size; -} - -function deriveHasActionableProposedPlan(input: { - readonly latestTurnId: string | null; - readonly proposedPlans: ReadonlyArray; -}): boolean { - const sorted = [...input.proposedPlans].toSorted( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), - ); - - let latestForTurn: ProjectionThreadProposedPlan | null = null; - if (input.latestTurnId !== null) { - for (let index = sorted.length - 1; index >= 0; index -= 1) { - const plan = sorted[index]; - if (plan?.turnId === input.latestTurnId) { - latestForTurn = plan; - break; - } - } - } - if (latestForTurn !== null) { - return latestForTurn.implementedAt === null; - } - - const latestPlan = sorted.at(-1) ?? null; - return latestPlan !== null && latestPlan.implementedAt === null; -} - function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md new file mode 100644 index 00000000..f255413e --- /dev/null +++ b/docs/upstream-sync-log.md @@ -0,0 +1,76 @@ +# Upstream Sync Log + +## 2026-04-17 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@9eb9c1a9` +- Upstream range reviewed: `pingdotgg/t3code@e3004ae806d4e9a81e03ff919f50d2d34c37ffe7..b2cca674dfdf93430460fe08e1ce0d857e30bd83` +- Upstream release window: `v0.0.17..v0.0.20` +- Fork PR: pending + +### Classification + +- `a3dadf31` `chore(release): prepare v0.0.17` — `SKIP`: upstream release-prep version churn only. +- `678f827f` `Remove Claude subscription-based model adjustment (#1899)` — `APPLY` +- `e2316814` `Fix worktree base branch updates for active draft (#1900)` — `SKIP`: workflow/UI-coupled draft branch behavior. +- `12c3af78` `feat(desktop): add "Copy Image" to right-click context menu (#1052)` — `SKIP`: desktop UI feature. +- `5fa09fa2` `[codex] fix composer footer compact layout (#1894)` — `SKIP`: web UI layout. +- `4ae9de31` `Stabilize auth session cookies per server mode (#1898)` — `MANUAL`: valuable auth hardening, but conflicted with Kodo auth/desktop runtime changes across multiple files. +- `58e5f714` `Add provider skill discovery (#1905)` — `MANUAL`: backend value exists, but upstream implementation is tightly coupled to composer/menu UI surfaces. +- `e0e01b4a` `Handle deleted git directories as non-repositories (#1907)` — `APPLY` +- `b80e8476` `Memoize derived thread reads (#1908)` — `SKIP`: frontend state/render optimization. +- `97880e88` `fix(web): resolve logical-to-physical key mismatch in project drag reorder (#1904)` — `SKIP`: web UI interaction. +- `26cc1fff` `Add assistant message copy action and harden related test/storage fallbacks (#1211)` — `SKIP`: chat UI feature. +- `1f4a3f65` `Fix opening urls wrapped across lines in the terminal (#1913)` — `SKIP`: terminal/web presentation behavior. +- `5467d119` `fix(web): prevent number-key shortcuts from hijacking input in focused editor (#1810)` — `SKIP`: web editor UX. +- `934037cb` `feat(web): add extensible command palette (#1103)` — `SKIP`: command-palette UI dominates the mixed change. +- `f9372a4c` `chore(desktop): separate dev AppUserModelID on Windows (#1934)` — `SKIP`: desktop shell presentation/platform polish. +- `f9019cd6` `Coalesce status refreshes by remote (#1940)` — `MANUAL`: adapted to keep Kodo non-interactive git hardening while porting the refresh-coalescing fix. +- `2fce84a1` `fix: quote editor launch args on Windows to support paths with spaces (#1805)` — `APPLY` +- `f59ee36b` `fix(web): allow concurrent browser tests to retry ports (#1951)` — `SKIP`: browser-test harness only. +- `7a008461` `fix: Align token usage metrics for both Claude and Codex (#1943)` — `APPLY` +- `94d13a2b` `Preserve live stream subscriptions across explicit reconnects (#1972)` — `SKIP`: reconnect UX/runtime mix touches frontend behavior. +- `96c9306d` `Migrate chat scrolling and branch lists to LegendList (#1953)` — `SKIP`: frontend virtualization/list rendering. +- `dff8784a` `window controls overlay (windows&linux) (#1969)` — `SKIP`: desktop/web presentation. +- `850c9125` `fix(desktop): increase backend readiness timeout from 10s to 30s (#1979)` — `SKIP`: desktop startup policy change conflicts with Kodo release/runtime path. +- `57d7746a` `fix(web): replace turn strip overlay gradients with mask-image fade (#1949)` — `SKIP`: styling. +- `f7fa62aa` `Add shell snapshot queries for orchestration state (#1973)` — `MANUAL`: backend value exists, but not needed for this batch. +- `1bf048eb` `fix: avoid copy button overlapping long code blocks (#1985)` — `SKIP`: chat UI. +- `f2205bdc` `Pad composer model picker to prevent ring clipping (#1992)` — `SKIP`: styling/layout. +- `801b83e9` `Allow empty server threads to bootstrap new worktrees (#1936)` — `SKIP`: mixed commit heavily coupled to branch-toolbar and chat UI. +- `77fcad35` `Prevent live thread branches from regressing to temp worktree names (#1995)` — `SKIP`: thread/branch presentation coupling. +- `047a0a69` `fix: add pointer cursor to the permissions mode select trigger (#1997)` — `SKIP`: styling. +- `9b29be91` `docs: Document environment prep before local development (#1975)` — `SKIP`: docs only. +- `5f7becf3` `feat: Add Kiro editor support to open picker (#1974)` — `APPLY` +- `cadd7086` `feat: show full thread title in a tooltip when hovering sidebar thread names (#1994)` — `SKIP`: sidebar UI. +- `f5ecca44` `Clear tracked RPCs on reconnect (#2000)` — `SKIP`: frontend reconnect behavior. +- `6f699346` `Use latest user message time for thread timestamps (#1996)` — `SKIP`: thread list UX. +- `d18e43b6` `fix: lost provider session recovery (#1938)` — `APPLY` +- `33dadb5a` `Fix thread timeline autoscroll and simplify branch state (#2002)` — `SKIP`: thread timeline UX. +- `569fea87` `Warm sidebar thread detail subscriptions (#2001)` — `SKIP`: sidebar performance/UI behavior. +- `5f7ec73a` `Fix new-thread draft reuse for worktree defaults (#2003)` — `SKIP`: new-thread frontend flow. +- `9dcea68b` `Refresh git status after branch rename and worktree setup (#2005)` — `MANUAL`: runtime fix applied while preserving Kodo server-test scaffolding. +- `008ac5c3` `Cache provider status and gate desktop startup (#1962)` — `MANUAL`: mixed startup/runtime change deferred because it conflicts with Kodo desktop startup behavior. +- `2e42f3fd` `Improve shell PATH hydration and fallback detection (#1799)` — `APPLY` +- `c9b07d66` `Backfill projected shell summaries and stale approval cleanup (#2004)` — `MANUAL`: projection and migration changes merged into Kodo persistence state. +- `0d280262` `fix(claude): emit plan events for TodoWrite during input streaming (#1541)` — `SKIP`: upstream plan/composer UI coupling. +- `409ff90a` `Nightly release channel (#2012)` — `SKIP`: release channel/branding flow diverges in Kodo. +- `9ff31f8c` `Fix nightly desktop product name (#2025)` — `SKIP`: nightly branding. +- `44afe784` `Add filesystem browse API and command palette project picker (#2024)` — `MANUAL`: backend browse API may be useful later, but the commit is tied to upstream command-palette UI and project-creation flow. +- `7968f278` `Fix terminal Cmd+Backspace on macOS (#2027)` — `SKIP`: frontend terminal UX. +- `28cb9db2` `feat(web): add tooltip to composer file mention pill (#1944)` — `SKIP`: UI. +- `68061af0` `Improve markdown file link UX (#1956)` — `SKIP`: frontend markdown UX. +- `5e1dd56d` `feat: add Launch Args setting for Claude provider (#1971)` — `SKIP`: settings-surface/UI coupling. +- `f9580ff0` `Default nightly desktop builds to the nightly update channel (#2049)` — `SKIP`: nightly packaging policy differs in Kodo. +- `5e13f535` `fix: remove trailing newline from CLAUDE.md symlink (#2052)` — `SKIP`: low-value repo housekeeping outside sync priorities. +- `d22c6f52` `fix: prevent user-input activities from leaking into pending approvals projection (#2051)` — `APPLY` +- `3e07f5a6` `feat: add Claude Opus 4.7 to built-in models (#2072)` — `SKIP`: visible provider/model surface change. +- `19d47408` `fix(web): prevent composer controls overlap on narrow windows (make plan sidebar responsive) (#1198)` — `SKIP`: responsive UI. +- `7a08fcf2` `fix(server): drop stale text generation options when resetting text-gen model selection (#2076)` — `MANUAL`: skipped because upstream settings model is behind Kodo’s preset/settings evolution. +- `188a40c3` `feat: configurable project grouping (#2055)` — `SKIP`: project grouping is a user-visible workflow/settings surface. +- `e0117b27` `Fix Claude Process leak[MEMORY INTENSIVE], archiving, and stale claude session monitoring. (#2042)` — `MANUAL`: large runtime/session rewrite deferred due extensive conflicts. +- `d90e15d1` `fix(server): extend negative repository identity cache ttl (#2083)` — `APPLY` +- `6891c77d` `Build for Windows ARM (#2080)` — `SKIP`: release/build pipeline conflicts with Kodo packaging changes. +- `b7df3dfc` `[codex] Fix Windows release manifest publishing (#2095)` — `SKIP`: release pipeline divergence. +- `54904386` `fix: guard against missing sidebarProjectGroupingOverrides in client settings (#2099)` — `SKIP`: client settings/frontend behavior. +- `b2cca674` `ci(release): install deps before finalize version bump (#2100)` — `SKIP`: release workflow divergence. 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); }, }); From be7628c915e6db187efe26328bedc62fedba0c76 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Fri, 17 Apr 2026 22:20:13 -0400 Subject: [PATCH 14/36] docs(sync): record PR link for 2026-04-17 upstream sync --- docs/upstream-sync-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index f255413e..648c8ec6 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -6,7 +6,7 @@ - Fork base: `boggedbrush/KodoCode@9eb9c1a9` - Upstream range reviewed: `pingdotgg/t3code@e3004ae806d4e9a81e03ff919f50d2d34c37ffe7..b2cca674dfdf93430460fe08e1ce0d857e30bd83` - Upstream release window: `v0.0.17..v0.0.20` -- Fork PR: pending +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 ### Classification From 3828c4760f0a15028968401b16fd4a41950e5528 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sat, 18 Apr 2026 09:04:05 -0400 Subject: [PATCH 15/36] [sync] Clean up invalid pending approval projections (#2106) Upstream-Ref: pingdotgg/t3code@f297e30 --- apps/server/src/persistence/Migrations.ts | 2 + ...pInvalidProjectionPendingApprovals.test.ts | 196 ++++++++++++++++++ ...leanupInvalidProjectionPendingApprovals.ts | 27 +++ 3 files changed, 225 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts create mode 100644 apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 01c649f7..023e3bca 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -37,6 +37,7 @@ 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. @@ -73,6 +74,7 @@ export const migrationEntries = [ [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/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) + `; +}); From 59fe8c18a98d4466ae36f504c3fe1f2b321cc346 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:36:47 -0400 Subject: [PATCH 16/36] fix(server): restore shell summary migration (cherry picked from commit 5fffa4dd3706770eaae4effa6a225dd42ac2b1c4) --- .../023_ProjectionThreadShellSummary.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts 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 + `; + } +}); From 15cc8c8fa0cacd37acc2cfc6b452e8d12dd9ca46 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sat, 18 Apr 2026 09:12:10 -0400 Subject: [PATCH 17/36] [sync-adapted] Honor gitignored workspace search and preserve provider bindings Upstream-Ref: pingdotgg/t3code@ed6b7fb Upstream-Ref: pingdotgg/t3code@721b6b4 Adapted-by: ported the workspace search layer wiring and provider stop-session persistence without dragging in unrelated test harness churn --- .../Layers/ProviderCommandReactor.ts | 14 ++--- .../src/provider/Layers/ProviderService.ts | 9 +++- .../Layers/ProviderSessionDirectory.test.ts | 15 +----- .../Layers/ProviderSessionDirectory.ts | 8 --- .../Services/ProviderSessionDirectory.ts | 4 -- apps/server/src/server.test.ts | 52 +++++++++++++++++++ apps/server/src/server.ts | 17 ++++-- 7 files changed, 78 insertions(+), 41 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7f833d9a..3890cc49 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -264,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, @@ -310,9 +310,6 @@ const make = Effect.gen(function* () { 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 sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -329,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, @@ -348,7 +343,6 @@ const make = Effect.gen(function* () { currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, - providerChanged, modelChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2e7dde68..06dca921 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -582,7 +582,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } - yield* directory.remove(input.threadId); + yield* directory.upsert({ + threadId: input.threadId, + provider: routed.adapter.provider, + status: "stopped", + runtimePayload: { + activeTurnId: null, + }, + }); 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..09dc6a36 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", () => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d6..27bd8517 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -132,13 +132,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")), @@ -149,7 +142,6 @@ const makeProviderSessionDirectory = Effect.gen(function* () { upsert, 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..f74d11af 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -41,10 +41,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/server.test.ts b/apps/server/src/server.test.ts index 410868b9..d6d28ad0 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(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index de8792f6..8a93605b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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( From 119dd9af3d76738db04903d715e163d0d0e2995c Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sat, 18 Apr 2026 09:12:20 -0400 Subject: [PATCH 18/36] [sync-frontend] Restore terminal toggle shortcut from focused terminal Upstream-Ref: pingdotgg/t3code@0f184c2 --- apps/web/src/components/ChatView.tsx | 4 ++-- apps/web/src/keybindings.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2c03cdd6..67d81e66 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3349,8 +3349,8 @@ 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, terminalState.terminalOpen, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c923f4ff..dedc7e85 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -129,6 +129,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", () => { From adf75c663ced736d0807242a041f67dc699aecad Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sat, 18 Apr 2026 09:13:31 -0400 Subject: [PATCH 19/36] docs(sync): record 2026-04-18 upstream sync review --- docs/upstream-sync-log.md | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 648c8ec6..9f5d4705 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,5 +1,79 @@ # Upstream Sync Log +## 2026-04-18 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@be7628c915e6db187efe26328bedc62fedba0c76` +- Upstream range reviewed: `pingdotgg/t3code@2d87574e62d616d890497d5b7d48201aa06d4dce..9df3c640210fecccb58f7fbc735f81ca0ee011bd` +- Upstream release window: `v0.0.20..main@2026-04-17` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 + +### Classification + +- `2d87574e` `chore(release): prepare v0.0.20` — `SKIP`: release version churn only. +- `505db9f6` `try out blacksmith for releases (#2101)` — `SKIP`: release pipeline divergence. +- `b991b9b9` `Revert to Github Runner for Windows (#2103)` — `SKIP`: release pipeline divergence. +- `ed6b7fbf` `fix(server): honor gitignored files in workspace search (#2078)` — `ADAPT`: ported the server-layer wiring and focused regression test without upstream harness churn. +- `8dba2d64` `Adopt Node-native TypeScript for desktop and server (#2098)` — `MANUAL`: broad tooling/runtime refactor across desktop, server, scripts, and contracts. +- `54179c86` `Update workflow to use ubuntu-24.04 runner (#2110)` — `SKIP`: release/CI runner policy is out of scope for this sync. +- `d8d32969` `Show thread status in command palette (#2107)` — `SKIP`: command-palette UI expansion conflicts with Kodo workflow boundaries. +- `a7a44d06` `Fix Windows PATH hydration and repair (#1729)` — `MANUAL`: valuable runtime hardening, but large shared shell/runtime rewrite on top of prior sync work. +- `f297e30e` `Clean up invalid pending approval projections (#2106)` — `APPLY` +- `df9d3400` `Modernize release workflow runners (#2129)` — `SKIP`: release workflow divergence. +- `40009735` `Extract backend startup readiness coordination (#2133)` — `MANUAL`: desktop startup refactor touches Kodo-specific startup/runtime behavior. +- `721b6b4c` `Preserve provider bindings when stopping sessions (#2125)` — `ADAPT`: ported the provider-binding persistence fix while keeping Kodo’s current tests and session scaffolding. +- `52a60678` `Throttle nightly release workflow to every 3 hours (#2134)` — `SKIP`: nightly release policy divergence. +- `39ca3ee8` `fix(web): bypass xterm for global terminal shortcuts (#1580)` — `SELECTIVE FRONTEND`: safe candidate, but deferred to keep this batch narrow after landing the higher-value terminal toggle fix. +- `ce94feee` `feat: add opencode provider support (#1758)` — `SKIP`: large new provider/product surface outside bounded sync scope. +- `60387f67` `fix: show restore defaults only on General settings (#1710)` — `SELECTIVE FRONTEND`: safe candidate, but deferred because it touches Kodo-owned settings surfacing and is lower priority than runtime work. +- `4e0c003e` `fix(web): allow deleting non-empty projects from the warning toast (#1264)` — `MANUAL`: mixed server/client project-deletion workflow change needs Kodo product judgment. +- `a3b1df52` `Add Claude Opus 4.5 to built-in Claude models (#2143)` — `SKIP`: visible provider/model surface change. +- `0f184c28` `fix(web): use capture-phase keydown listener so CTRL+J toggles terminal from terminal focus on Windows (#2113) (#2142)` — `SELECTIVE FRONTEND` +- `9c64f12e` `Add ACP support with Cursor provider (#1355)` — `SKIP`: major new provider/runtime architecture and package surface. +- `29cb917a` `Guard release workflow jobs from upstream failures (#2146)` — `SKIP`: release workflow divergence. +- `8ac57f79` `Guard release workflow jobs on upstream success (#2147)` — `SKIP`: release workflow divergence. +- `9df3c640` `Use GitHub App token for release uploads (#2149)` — `SKIP`: release workflow divergence. + +### Applied changes + +- `f297e30e` Added migration `025_CleanupInvalidProjectionPendingApprovals` to scrub invalid persisted pending-approval rows. +- Restored missing local migration file `023_ProjectionThreadShellSummary` from Kodo history so the sync branch’s migration registry is internally consistent. + +### Adapted changes + +- `ed6b7fbf` Wired `WorkspaceEntries` through `GitCore` in [`apps/server/src/server.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/server.ts) and added a regression test in [`apps/server/src/server.test.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/server.test.ts) so workspace search respects gitignored paths. +- `721b6b4c` Updated provider stop-session handling in [`apps/server/src/provider/Layers/ProviderService.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/provider/Layers/ProviderService.ts) and [`apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) to preserve provider bindings after stop/restart cycles. + +### Selective frontend changes ported + +- `0f184c28` Updated [`apps/web/src/components/ChatView.tsx`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/web/src/components/ChatView.tsx) so the terminal toggle shortcut is captured while terminal focus is inside xterm on Windows, with regression coverage in [`apps/web/src/keybindings.test.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/web/src/keybindings.test.ts). + +### Manual-review candidates + +- `8dba2d64` Node-native TypeScript adoption: too broad for a bounded sync. +- `a7a44d06` Windows PATH hydration/repair: valuable, but large shared-runtime adaptation. +- `40009735` Backend startup readiness extraction: overlaps Kodo desktop startup policy. +- `4e0c003e` Non-empty project deletion flow: mixed server/client workflow needs product review. + +### Skipped changes + +- Release workflow and packaging changes: `2d87574e`, `505db9f6`, `b991b9b9`, `54179c86`, `df9d3400`, `52a60678`, `29cb917a`, `8ac57f79`, `9df3c640` +- Product-surface/provider expansions: `ce94feee`, `a3b1df52`, `9c64f12e` +- Command-palette UI expansion: `d8d32969` + +### Deferred selective frontend candidates + +- `39ca3ee8` Global terminal shortcuts from focused xterm: deferred after landing the narrower Ctrl+J fix first; looks safe for a future PR. +- `60387f67` Restore-defaults button limited to General settings: deferred because Kodo’s settings surface is already diverging; likely safe for a future PR with visual review. + +### Checks + +- `bun fmt` ✅ +- `bun lint` ✅ +- `bun typecheck` ⚠️ fails in pre-existing `apps/web` Base UI / `ButtonProps` typing errors unrelated to this sync batch. +- `cd apps/web && bun run test src/keybindings.test.ts` ✅ +- `cd apps/server && bun run test ...` ⚠️ blocked by pre-existing server test environment issues: missing `multipasta/node` resolution and missing migration `023` before the local restore commit. + ## 2026-04-17 - Fork branch: `sync/upstream-2026-04-17` From 3c6bcf4b82e90b3f815026cdb47ae1b3b8b38dfe Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sun, 19 Apr 2026 11:38:58 -0400 Subject: [PATCH 20/36] chore(sync): refresh 2026-04-19 upstream review No new upstream main commits beyond 9df3c640 were available. Also fixes branch-local typecheck drift so bun fmt, bun lint, and bun typecheck pass cleanly on the active sync PR branch. --- apps/server/src/git/Layers/GitCore.ts | 3 +- .../Layers/ProjectionPipeline.test.ts | 106 +++++++++--------- .../Layers/ProjectionPipeline.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 14 +-- .../src/provider/Layers/ClaudeAdapter.ts | 3 +- .../src/provider/Layers/CodexAdapter.test.ts | 1 - docs/upstream-sync-log.md | 47 ++++++++ 7 files changed, 111 insertions(+), 65 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 834e1b59..c00e4c6f 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -980,8 +980,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return true as const; }); - const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { + const statusRemoteRefreshCache = yield* Cache.makeWith({ capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, + lookup: refreshStatusRemoteCacheEntry, // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. timeToLive: (exit) => Exit.isSuccess(exit) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index d4a3c87a..617695ef 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1516,16 +1516,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "project.created", - eventId: EventId.make("evt-stale-approval-1"), + eventId: EventId.makeUnsafe("evt-stale-approval-1"), aggregateKind: "project", - aggregateId: ProjectId.make("project-stale-approval"), + aggregateId: ProjectId.makeUnsafe("project-stale-approval"), occurredAt: "2026-02-26T12:30:00.000Z", - commandId: CommandId.make("cmd-stale-approval-1"), + commandId: CommandId.makeUnsafe("cmd-stale-approval-1"), causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-approval-1"), + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-1"), metadata: {}, payload: { - projectId: ProjectId.make("project-stale-approval"), + projectId: ProjectId.makeUnsafe("project-stale-approval"), title: "Project Stale Approval", workspaceRoot: "/tmp/project-stale-approval", defaultModelSelection: null, @@ -1537,17 +1537,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.created", - eventId: EventId.make("evt-stale-approval-2"), + eventId: EventId.makeUnsafe("evt-stale-approval-2"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-stale-approval"), occurredAt: "2026-02-26T12:30:01.000Z", - commandId: CommandId.make("cmd-stale-approval-2"), + commandId: CommandId.makeUnsafe("cmd-stale-approval-2"), causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-approval-2"), + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-2"), metadata: {}, payload: { - threadId: ThreadId.make("thread-stale-approval"), - projectId: ProjectId.make("project-stale-approval"), + threadId: ThreadId.makeUnsafe("thread-stale-approval"), + projectId: ProjectId.makeUnsafe("project-stale-approval"), title: "Thread Stale Approval", modelSelection: { provider: "codex", @@ -1564,18 +1564,18 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.activity-appended", - eventId: EventId.make("evt-stale-approval-3"), + eventId: EventId.makeUnsafe("evt-stale-approval-3"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-stale-approval"), occurredAt: "2026-02-26T12:30:02.000Z", - commandId: CommandId.make("cmd-stale-approval-3"), + commandId: CommandId.makeUnsafe("cmd-stale-approval-3"), causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-approval-3"), + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-3"), metadata: {}, payload: { - threadId: ThreadId.make("thread-stale-approval"), + threadId: ThreadId.makeUnsafe("thread-stale-approval"), activity: { - id: EventId.make("activity-stale-approval-requested"), + id: EventId.makeUnsafe("activity-stale-approval-requested"), tone: "approval", kind: "approval.requested", summary: "Command approval requested", @@ -1591,18 +1591,18 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.activity-appended", - eventId: EventId.make("evt-stale-approval-4"), + eventId: EventId.makeUnsafe("evt-stale-approval-4"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-stale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-stale-approval"), occurredAt: "2026-02-26T12:30:03.000Z", - commandId: CommandId.make("cmd-stale-approval-4"), + commandId: CommandId.makeUnsafe("cmd-stale-approval-4"), causationEventId: null, - correlationId: CorrelationId.make("cmd-stale-approval-4"), + correlationId: CorrelationId.makeUnsafe("cmd-stale-approval-4"), metadata: {}, payload: { - threadId: ThreadId.make("thread-stale-approval"), + threadId: ThreadId.makeUnsafe("thread-stale-approval"), activity: { - id: EventId.make("activity-stale-approval-failed"), + id: EventId.makeUnsafe("activity-stale-approval-failed"), tone: "error", kind: "provider.approval.respond.failed", summary: "Provider approval response failed", @@ -1659,16 +1659,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "project.created", - eventId: EventId.make("evt-nonstale-approval-1"), + eventId: EventId.makeUnsafe("evt-nonstale-approval-1"), aggregateKind: "project", - aggregateId: ProjectId.make("project-nonstale-approval"), + aggregateId: ProjectId.makeUnsafe("project-nonstale-approval"), occurredAt: "2026-02-26T12:45:00.000Z", - commandId: CommandId.make("cmd-nonstale-approval-1"), + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-1"), causationEventId: null, - correlationId: CorrelationId.make("cmd-nonstale-approval-1"), + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-1"), metadata: {}, payload: { - projectId: ProjectId.make("project-nonstale-approval"), + projectId: ProjectId.makeUnsafe("project-nonstale-approval"), title: "Project Non-Stale Approval", workspaceRoot: "/tmp/project-nonstale-approval", defaultModelSelection: null, @@ -1680,17 +1680,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.created", - eventId: EventId.make("evt-nonstale-approval-2"), + eventId: EventId.makeUnsafe("evt-nonstale-approval-2"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-nonstale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), occurredAt: "2026-02-26T12:45:01.000Z", - commandId: CommandId.make("cmd-nonstale-approval-2"), + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-2"), causationEventId: null, - correlationId: CorrelationId.make("cmd-nonstale-approval-2"), + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-2"), metadata: {}, payload: { - threadId: ThreadId.make("thread-nonstale-approval"), - projectId: ProjectId.make("project-nonstale-approval"), + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), + projectId: ProjectId.makeUnsafe("project-nonstale-approval"), title: "Thread Non-Stale Approval", modelSelection: { provider: "codex", @@ -1707,18 +1707,18 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.activity-appended", - eventId: EventId.make("evt-nonstale-approval-3"), + eventId: EventId.makeUnsafe("evt-nonstale-approval-3"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-nonstale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), occurredAt: "2026-02-26T12:45:02.000Z", - commandId: CommandId.make("cmd-nonstale-approval-3"), + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-3"), causationEventId: null, - correlationId: CorrelationId.make("cmd-nonstale-approval-3"), + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-3"), metadata: {}, payload: { - threadId: ThreadId.make("thread-nonstale-approval"), + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), activity: { - id: EventId.make("activity-nonstale-approval-requested"), + id: EventId.makeUnsafe("activity-nonstale-approval-requested"), tone: "approval", kind: "approval.requested", summary: "Command approval requested", @@ -1734,18 +1734,18 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.activity-appended", - eventId: EventId.make("evt-nonstale-approval-4"), + eventId: EventId.makeUnsafe("evt-nonstale-approval-4"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-nonstale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), occurredAt: "2026-02-26T12:45:03.000Z", - commandId: CommandId.make("cmd-nonstale-approval-4"), + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-4"), causationEventId: null, - correlationId: CorrelationId.make("cmd-nonstale-approval-4"), + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-4"), metadata: {}, payload: { - threadId: ThreadId.make("thread-nonstale-approval"), + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), activity: { - id: EventId.make("activity-nonstale-approval-failed-existing"), + id: EventId.makeUnsafe("activity-nonstale-approval-failed-existing"), tone: "error", kind: "provider.approval.respond.failed", summary: "Provider approval response failed", @@ -1753,7 +1753,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { requestId: "approval-request-nonstale-existing", detail: "Provider timed out while responding to approval request", }, - turnId: TurnId.make("turn-nonstale-failure"), + turnId: TurnId.makeUnsafe("turn-nonstale-failure"), createdAt: "2026-02-26T12:45:03.000Z", }, }, @@ -1761,18 +1761,18 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* appendAndProject({ type: "thread.activity-appended", - eventId: EventId.make("evt-nonstale-approval-5"), + eventId: EventId.makeUnsafe("evt-nonstale-approval-5"), aggregateKind: "thread", - aggregateId: ThreadId.make("thread-nonstale-approval"), + aggregateId: ThreadId.makeUnsafe("thread-nonstale-approval"), occurredAt: "2026-02-26T12:45:04.000Z", - commandId: CommandId.make("cmd-nonstale-approval-5"), + commandId: CommandId.makeUnsafe("cmd-nonstale-approval-5"), causationEventId: null, - correlationId: CorrelationId.make("cmd-nonstale-approval-5"), + correlationId: CorrelationId.makeUnsafe("cmd-nonstale-approval-5"), metadata: {}, payload: { - threadId: ThreadId.make("thread-nonstale-approval"), + threadId: ThreadId.makeUnsafe("thread-nonstale-approval"), activity: { - id: EventId.make("activity-nonstale-approval-failed-missing"), + id: EventId.makeUnsafe("activity-nonstale-approval-failed-missing"), tone: "error", kind: "provider.approval.respond.failed", summary: "Provider approval response failed", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 5a743de6..5189a88a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -86,7 +86,7 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { return null; } const requestId = (payload as Record).requestId; - return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; + return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; } function isStalePendingApprovalFailureDetail(detail: string | null): boolean { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ddff7005..94441d8e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1380,10 +1380,10 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-stale"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.makeUnsafe("cmd-session-set-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), session: { - threadId: ThreadId.make("thread-1"), + threadId: ThreadId.makeUnsafe("thread-1"), status: "ready", providerName: "codex", runtimeMode: "approval-required", @@ -1398,8 +1398,8 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-stale"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-stale"), + threadId: ThreadId.makeUnsafe("thread-1"), message: { messageId: asMessageId("user-message-stale"), role: "user", @@ -1416,7 +1416,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - threadId: ThreadId.make("thread-1"), + threadId: ThreadId.makeUnsafe("thread-1"), modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -1424,7 +1424,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", }); expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ - threadId: ThreadId.make("thread-1"), + threadId: ThreadId.makeUnsafe("thread-1"), }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 909d44f6..a4adde9f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -18,7 +18,6 @@ import { type SettingSource, type SDKUserMessage, ModelUsage, - NonNullableUsage, } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, @@ -291,7 +290,7 @@ function maxClaudeContextWindowFromModelUsage( } function normalizeClaudeTokenUsage( - value: NonNullableUsage | undefined, + value: unknown, contextWindow?: number, ): ThreadTokenUsageSnapshot | undefined { if (!value || typeof value !== "object") { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index db91e8da..3bfa65d0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -144,7 +144,6 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory 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/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 9f5d4705..13b6d99c 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,5 +1,52 @@ # Upstream Sync Log +## 2026-04-19 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@adf75c663ced736d0807242a041f67dc699aecad` +- Upstream range reviewed: `pingdotgg/t3code@9df3c640210fecccb58f7fbc735f81ca0ee011bd..9df3c640210fecccb58f7fbc735f81ca0ee011bd` +- Upstream release window: `main unchanged since 2026-04-18 review` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 + +### Classification + +- No new upstream `main` commits were available after `9df3c640`, so there were no new `APPLY`, `ADAPT`, `SELECTIVE FRONTEND`, `MANUAL REVIEW`, or `SKIP` classifications in this run. + +### Applied changes + +- None. Upstream `main` had no new commits beyond the previously reviewed boundary. +- Local branch follow-up: repaired sync-branch typecheck drift caused by Effect cache API and branded-id helper changes so required validation passes cleanly. + +### Adapted changes + +- None. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- `8dba2d64` Node-native TypeScript adoption: still too broad for a bounded sync. +- `a7a44d06` Windows PATH hydration/repair: still valuable, but remains a large shared-runtime adaptation. +- `40009735` Backend startup readiness extraction: still overlaps Kodo desktop startup policy. +- `4e0c003e` Non-empty project deletion flow: still a mixed server/client workflow needing product review. + +### Skipped changes + +- None newly skipped in this run because there were no new upstream commits to classify. + +### Deferred selective frontend candidates + +- `39ca3ee8` Global terminal shortcuts from focused xterm: still deferred; looks safe for a future PR. +- `60387f67` Restore-defaults button limited to General settings: still deferred pending visual review against Kodo settings divergence. + +### Checks + +- `bun fmt` ✅ +- `bun lint` ✅ +- `bun typecheck` ✅ + ## 2026-04-18 - Fork branch: `sync/upstream-2026-04-17` From ca6806ac1dd57fb4b657426a80e85a221a9c05fa Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sun, 19 Apr 2026 12:36:23 -0400 Subject: [PATCH 21/36] [sync-adapted] Port filesystem browse for project add Upstream-Ref: pingdotgg/t3code@44afe784 Adapted-by: ported the backend browse API and a focused Sidebar folder browser without taking upstream command-palette or project-flow rewrites --- apps/server/src/server.test.ts | 28 ++ .../workspace/Layers/WorkspaceEntries.test.ts | 32 +++ .../src/workspace/Layers/WorkspaceEntries.ts | 108 ++++++- .../workspace/Services/WorkspaceEntries.ts | 25 +- apps/server/src/ws.ts | 15 + apps/web/src/components/Sidebar.tsx | 247 ++++++++++++---- apps/web/src/lib/projectPaths.test.ts | 50 ++++ apps/web/src/lib/projectPaths.ts | 266 ++++++++++++++++++ apps/web/src/wsNativeApi.test.ts | 18 ++ apps/web/src/wsNativeApi.ts | 3 + apps/web/src/wsRpcClient.ts | 6 + docs/upstream-sync-log.md | 11 +- packages/contracts/src/filesystem.ts | 31 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 4 + packages/contracts/src/rpc.ts | 11 + 16 files changed, 804 insertions(+), 52 deletions(-) create mode 100644 apps/web/src/lib/projectPaths.test.ts create mode 100644 apps/web/src/lib/projectPaths.ts create mode 100644 packages/contracts/src/filesystem.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d6d28ad0..51c0e75d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2007,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/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/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 58f4ef8e..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, @@ -685,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/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f55d97d3..eb612d25 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,15 @@ 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 { + canNavigateUp, + ensureBrowseDirectoryPath, + findProjectByPath, + getBrowseParentPath, + inferProjectTitleFromPath, + normalizeProjectPathForDispatch, +} from "../lib/projectPaths"; import { useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -108,7 +117,6 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; import { getProjectThreadsForSidebar, getVisibleSidebarThreadIds, @@ -728,9 +736,14 @@ 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 addProjectInputRef = useRef(null); const [renamingProjectId, setRenamingProjectId] = useState(null); const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); @@ -756,13 +769,11 @@ 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 shouldShowProjectPathEntry = addingProject; const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -854,7 +865,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; @@ -864,10 +875,16 @@ export default function Sidebar() { setIsAddingProject(false); setNewCwd(""); setAddProjectError(null); + setBrowsePanelOpen(false); + setBrowsePath(""); + setBrowseEntries([]); + setBrowseCurrentDirectory(null); + setBrowseError(null); + setIsBrowsingFilesystem(false); setAddingProject(false); }; - const existing = projects.find((project) => project.cwd === cwd); + const existing = findProjectByPath(projects, cwd); if (existing) { focusMostRecentThreadForProject(existing.id); finishAddingProject(); @@ -876,7 +893,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 +914,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 +924,6 @@ export default function Sidebar() { handleNewThread, isAddingProject, projects, - shouldBrowseForProjectImmediately, appSettings.defaultThreadEnvMode, ], ); @@ -926,31 +934,81 @@ 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 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 = () => { - setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { - void handlePickFolder(); + if (addingProject) { + setAddingProject(false); + setAddProjectError(null); + setBrowsePanelOpen(false); + setBrowseError(null); + setBrowseEntries([]); + setBrowseCurrentDirectory(null); + setBrowsePath(""); return; } - setAddingProject((prev) => !prev); + setAddProjectError(null); + setAddingProject(true); + if (isElectron) { + openBrowsePanel(); + } }; const cancelRename = useCallback(() => { @@ -2274,17 +2332,6 @@ export default function Sidebar() { {shouldShowProjectPathEntry && (
- {isElectron && ( - - )}
+
+ +
+ {browsePanelOpen && ( +
+
+ + { + setBrowsePath(event.target.value); + setBrowseError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + void browseFilesystem(browsePath); + } + }} + /> + +
+ {browseCurrentDirectory && ( + + )} +
+ {browseEntries.length === 0 ? ( +

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

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

+ {browseError} +

+ )} +
+ )} {addProjectError && (

{addProjectError} 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/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/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 13b6d99c..7dfd9d4e 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -11,15 +11,21 @@ ### Classification - No new upstream `main` commits were available after `9df3c640`, so there were no new `APPLY`, `ADAPT`, `SELECTIVE FRONTEND`, `MANUAL REVIEW`, or `SKIP` classifications in this run. +- User-requested follow-up: `44afe784` `Add filesystem browse API and command palette project picker (#2024)` — `ADAPT`: ported the backend filesystem browse API and a focused Sidebar folder browser without importing upstream command-palette or project-flow churn. ### Applied changes - None. Upstream `main` had no new commits beyond the previously reviewed boundary. - Local branch follow-up: repaired sync-branch typecheck drift caused by Effect cache API and branded-id helper changes so required validation passes cleanly. +- Added `filesystem.browse` contracts/RPC wiring and server-side directory browsing through `WorkspaceEntries`. +- Added a Kodo-specific inline folder browser to the Sidebar add-project flow so desktop users are no longer forced through the system folder picker. ### Adapted changes -- None. +- `44afe784` selectively adapted: +- kept the browse backend/API pieces +- omitted the upstream command palette and project picker architecture +- wired the feature into Kodo's existing Sidebar add-project UI instead ### Selective frontend changes ported @@ -46,6 +52,9 @@ - `bun fmt` ✅ - `bun lint` ✅ - `bun typecheck` ✅ +- `cd apps/server && bun run test src/workspace/Layers/WorkspaceEntries.test.ts -t browse` ✅ +- `cd apps/server && bun run test src/server.test.ts -t filesystem.browse` ✅ +- `cd apps/web && bun run test src/wsNativeApi.test.ts src/lib/projectPaths.test.ts` ✅ ## 2026-04-18 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/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, From f23a1e82ef306cb874e17692ae8ddf2fe02a4bc3 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sun, 19 Apr 2026 13:54:20 -0400 Subject: [PATCH 22/36] fix(contracts): disable tsdown dts bundling The contracts package already serves types from src/index.ts, so bundling declaration output during workspace builds is redundant and was tripping rolldown-plugin-dts on Windows during dev:server startup. --- packages/contracts/package.json | 4 ++-- packages/contracts/tsdown.config.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 packages/contracts/tsdown.config.ts 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/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, +}); From f7c93dba7c10a045cb07a097a467fba88597b711 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Sun, 19 Apr 2026 14:14:02 -0400 Subject: [PATCH 23/36] Fix Windows dev server startup --- apps/server/src/bin.ts | 23 +++-- apps/server/src/checkpointing/Diffs.ts | 77 ++++++++++++--- apps/server/src/http.ts | 4 +- apps/server/src/imageMime.test.ts | 11 +++ apps/server/src/imageMime.ts | 4 +- apps/server/src/mime.ts | 54 ++++++++++ apps/server/src/open.ts | 33 ++++--- .../Layers/ProjectionSnapshotQuery.ts | 22 +++-- .../persistence/Layers/ProjectionThreads.ts | 44 +++++++-- .../server/src/persistence/interactionMode.ts | 24 +++++ apps/server/src/server.ts | 2 +- bun.lock | 1 + package.json | 17 ++-- scripts/dev-runner.test.ts | 10 +- scripts/dev-runner.ts | 98 ++++++++++++++++--- scripts/run-local-turbo.mjs | 39 ++++++++ scripts/typecheck-all.mjs | 32 ++++++ 17 files changed, 415 insertions(+), 80 deletions(-) create mode 100644 apps/server/src/mime.ts create mode 100644 apps/server/src/persistence/interactionMode.ts create mode 100644 scripts/run-local-turbo.mjs create mode 100644 scripts/typecheck-all.mjs diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 64d747e8..a3d02ac7 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,20 @@ 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); +if (isBunRuntime) { + const BunRuntime = await import("@effect/platform-bun/BunRuntime"); + const BunServices = await import("@effect/platform-bun/BunServices"); + const cliRuntimeLayer = Layer.mergeAll(BunServices.layer, NetService.layer); + + BunRuntime.runMain(main.pipe(Effect.provide(cliRuntimeLayer))); +} else { + const NodeRuntime = await import("@effect/platform-node/NodeRuntime"); + const NodeServices = await import("@effect/platform-node/NodeServices"); + const cliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + + NodeRuntime.runMain(main.pipe(Effect.provide(cliRuntimeLayer))); +} diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index c2f867b9..e9f5aff8 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,11 +1,31 @@ -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; +} + +function normalizeDiffPath(rawPath: string): string { + return rawPath.replace(/^b\//, "").trim(); +} + +function finalizeFile( + summaries: Array, + current: MutableTurnDiffFileSummary | null, +): MutableTurnDiffFileSummary | null { + if (!current || current.path.length === 0) { + return null; + } + + summaries.push(current); + return null; +} + export function parseTurnDiffFilesFromUnifiedDiff( diff: string, ): ReadonlyArray { @@ -14,14 +34,49 @@ 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: MutableTurnDiffFileSummary | null = null; + + for (const line of normalized.split("\n")) { + if (line.startsWith("diff --git ")) { + current = finalizeFile(summaries, current); + const match = /^diff --git a\/.+? b\/(.+)$/.exec(line); + current = { + path: match ? normalizeDiffPath(match[1] ?? "") : "", + additions: 0, + deletions: 0, + }; + continue; + } + + if (!current) { + continue; + } + + if (line.startsWith("rename to ")) { + current.path = normalizeDiffPath(line.slice("rename to ".length)); + continue; + } + + if (line.startsWith("+++ ")) { + const nextPath = line.slice(4).trim(); + if (nextPath !== "/dev/null") { + current.path = normalizeDiffPath(nextPath); + } + continue; + } + + if (line.startsWith("+") && !line.startsWith("+++")) { + current.additions += 1; + continue; + } + + if (line.startsWith("-") && !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/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/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.ts b/apps/server/src/open.ts index f8df8953..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 @@ -329,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/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/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/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/server.ts b/apps/server/src/server.ts index 8a93605b..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 { 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/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/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/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); + } +} From 3cf27df15ae50d6e3d7b310807e53b9640005a6a Mon Sep 17 00:00:00 2001 From: Adam Mansfield Date: Mon, 20 Apr 2026 02:35:37 -0400 Subject: [PATCH 24/36] chore(turbo): pass through PATHEXT (#2184) (cherry picked from commit f6978db60553716a9974b9e85f855bae8124905d) --- turbo.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index e6504927..96fee3ef 100644 --- a/turbo.json +++ b/turbo.json @@ -23,7 +23,11 @@ "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"], From 7cbb5901faeee4928a13ef988a772af4d48b1c70 Mon Sep 17 00:00:00 2001 From: reasv <7143787+reasv@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:13:20 +0200 Subject: [PATCH 25/36] fix(server): prevent probeClaudeCapabilities from wasting API requests (#2192) Co-authored-by: Claude Opus 4.6 Co-authored-by: Julius Marminge (cherry picked from commit 8dbcf92a0d125050988474f258df3e55c538efec) --- .../src/provider/Layers/ClaudeProvider.ts | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index d11f7cc7..4f5bb666 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -9,7 +9,7 @@ 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 { buildServerProvider, @@ -340,13 +340,23 @@ function claudeAuthMetadata(input: { 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. @@ -355,13 +365,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: () => {}, }, From 85bba65a63e2067706ef4b0c325a76eff40e72f7 Mon Sep 17 00:00:00 2001 From: "Alton Johnson, OSCP, OSCE" <4023423+altjx@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:06:19 -0400 Subject: [PATCH 26/36] Expand leading ~ in Codex home paths before exporting CODEX_HOME (#2210) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: = <=> (cherry picked from commit 20f346d8ef58b21c339bc956c1728f4f16d09a87) --- apps/server/src/codexAppServerManager.ts | 5 +-- .../src/git/Layers/CodexTextGeneration.ts | 7 ++-- apps/server/src/os-jank.ts | 10 ++---- apps/server/src/pathExpansion.test.ts | 33 +++++++++++++++++++ apps/server/src/pathExpansion.ts | 23 +++++++++++++ .../src/provider/Layers/CodexProvider.ts | 3 +- apps/server/src/provider/codexAppServer.ts | 3 +- .../src/workspace/Layers/WorkspacePaths.ts | 14 ++------ 8 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/pathExpansion.test.ts create mode 100644 apps/server/src/pathExpansion.ts diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 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 { + 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/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 74319d58..8d4502d4 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 = { @@ -323,7 +324,7 @@ const runCodexCommand = Effect.fn("runCodexCommand")(function* (args: ReadonlyAr shell: process.platform === "win32", env: { ...process.env, - ...(codexSettings.homePath ? { CODEX_HOME: codexSettings.homePath } : {}), + ...(codexSettings.homePath ? { CODEX_HOME: expandHomePath(codexSettings.homePath) } : {}), }, }); return yield* spawnAndCollect(codexSettings.binaryPath, command); diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 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/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))); From d3fbe246e486e17c7c819bdd046c02447d8680ad Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Mon, 20 Apr 2026 09:10:14 -0400 Subject: [PATCH 27/36] sync: port bounded upstream runtime fixes --- docs/upstream-sync-log.md | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 7dfd9d4e..ce133157 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,5 +1,64 @@ # Upstream Sync Log +## 2026-04-20 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@f7c93dba7c10a045cb07a097a467fba88597b711` +- Upstream range reviewed: `pingdotgg/t3code@9df3c640210fecccb58f7fbc735f81ca0ee011bd..f6978db60553716a9974b9e85f855bae8124905d` +- Upstream release window: `v0.0.21-nightly.20260420.77` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 + +### Classification + +- `c83bc5d4` `fix(release): use v tag format for nightly releases (#2186)` — `SKIP`: release workflow divergence. +- `20f346d8` `Expand leading ~ in Codex home paths before exporting CODEX_HOME (#2210)` — `ADAPT`: ported into Kodo's current Codex runtime files and consolidated duplicate `~` path expansion helpers behind a shared server utility. +- `57b59b5b` `Devcontainer / IDE updates (#2208)` — `SKIP`: devcontainer/editor housekeeping outside sync priorities. +- `37965da0` `fix(server): handle OpenCode text response format in commit message generation (#2202)` — `SKIP`: current sync branch does not carry the upstream OpenCode text-generation layer shape this fix targets, so importing it would reintroduce divergent provider code. +- `8dbcf92a` `fix(server): prevent probeClaudeCapabilities from wasting API requests (#2192)` — `ADAPT`: kept Kodo's subscription-only Claude probe but switched it to the no-prompt initialization pattern so it no longer burns Anthropic requests. +- `66c326b8` `Redesign model picker with favorites and search (#2153)` — `SKIP`: broad model-picker UX rewrite conflicts with Kodo-specific composer and settings behavior. +- `3b98fe35` ``effect-codex-app-server` (#1942)` — `MANUAL REVIEW`: very large Codex runtime/package refactor across server, provider layers, and generated schema surfaces. +- `306ec4bb` `Refactor OpenCode lifecycle and structured output handling (#2218)` — `MANUAL REVIEW`: wide provider/runtime rewrite spanning server and web integration, too large for this bounded sync. +- `6d1505c9` `fix: Change right panel sheet to be below title bar / action bar (#2224)` — `SELECTIVE FRONTEND`: localized layout correction on an existing surface, but deferred pending visual review against Kodo's desktop/title-bar divergence. +- `de05b0c9` `fix(web): restore manual sort drag and keep per-group expand state (#2221)` — `MANUAL REVIEW`: touches sidebar grouping and project/worktree state behavior that Kodo intentionally owns. +- `f6978db6` `chore(turbo): pass through PATHEXT (#2184)` — `APPLY` + +### Applied changes + +- `f6978db6` Added `PATHEXT` to Turbo passthrough env so Windows command resolution survives task execution without dropping Kodo's existing dev-runner passthrough vars. + +### Adapted changes + +- `20f346d8` Added [`apps/server/src/pathExpansion.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/pathExpansion.ts) and [`apps/server/src/pathExpansion.test.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/pathExpansion.test.ts), then wired Codex session startup, Codex CLI version checks, Codex git text generation, and Codex provider probes through expanded `CODEX_HOME` values. +- `20f346d8` follow-up cleanup: repointed [`apps/server/src/os-jank.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/os-jank.ts) and [`apps/server/src/workspace/Layers/WorkspacePaths.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/workspace/Layers/WorkspacePaths.ts) at the same helper to avoid carrying three separate `~` expansion implementations. +- `8dbcf92a` Updated [`apps/server/src/provider/Layers/ClaudeProvider.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/provider/Layers/ClaudeProvider.ts) so the Claude capability probe uses a never-yielding async prompt and abort-driven initialization instead of a real prompt/API request. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- `3b98fe35` `effect-codex-app-server`: large server/provider/generated-schema refactor that needs its own focused sync. +- `306ec4bb` OpenCode lifecycle and structured output handling: large provider/runtime rewrite beyond this run's budget. +- `de05b0c9` Sidebar manual-sort and per-group expand-state repair: mixed workflow/state change that needs Kodo product review. + +### Skipped changes + +- Release / packaging / dev-only changes: `c83bc5d4`, `57b59b5b` +- Upstream-only or currently inapplicable provider-layer fix: `37965da0` +- Kodo-divergent product-surface rewrite: `66c326b8` + +### Deferred selective frontend candidates + +- `6d1505c9` Right panel sheet below title bar/action bar: deferred because Kodo's desktop/title-bar behavior already diverges; likely safe for a future PR after visual review. + +### Checks + +- `bun fmt` ⚠️ not runnable in this automation environment because `bun` is not installed. +- `bun lint` ⚠️ not runnable in this automation environment because `bun` is not installed. +- `bun typecheck` ⚠️ not runnable in this automation environment because `bun` is not installed. +- Focused runtime validation was also blocked because `node` is not installed in this automation environment. + ## 2026-04-19 - Fork branch: `sync/upstream-2026-04-17` From 13ea716ce3a553dd62b53e6110c3d3f988d34566 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:49:35 -0400 Subject: [PATCH 28/36] fix(server): resolve review regressions in session recovery and diff parsing --- apps/server/src/checkpointing/Diffs.test.ts | 35 ++++ apps/server/src/checkpointing/Diffs.ts | 142 ++++++++++++++-- apps/server/src/git/Layers/GitCore.test.ts | 159 +++++++++--------- apps/server/src/git/Layers/GitCore.ts | 27 ++- .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../src/provider/Layers/ProviderService.ts | 13 +- .../Layers/ProviderSessionDirectory.test.ts | 24 +++ .../Layers/ProviderSessionDirectory.ts | 8 + .../Services/ProviderSessionDirectory.ts | 8 + 9 files changed, 318 insertions(+), 99 deletions(-) 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 e9f5aff8..23d323ec 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -10,19 +10,122 @@ interface MutableTurnDiffFileSummary { deletions: number; } +interface MutableTurnDiffFileState extends MutableTurnDiffFileSummary { + inHeader: boolean; +} + function normalizeDiffPath(rawPath: string): string { return rawPath.replace(/^b\//, "").trim(); } function finalizeFile( summaries: Array, - current: MutableTurnDiffFileSummary | null, -): MutableTurnDiffFileSummary | null { + current: MutableTurnDiffFileState | null, +): MutableTurnDiffFileState | null { if (!current || current.path.length === 0) { return null; } - summaries.push(current); + 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; } @@ -35,16 +138,19 @@ export function parseTurnDiffFilesFromUnifiedDiff( } const summaries: Array = []; - let current: MutableTurnDiffFileSummary | null = null; + let current: MutableTurnDiffFileState | null = null; for (const line of normalized.split("\n")) { if (line.startsWith("diff --git ")) { current = finalizeFile(summaries, current); - const match = /^diff --git a\/.+? b\/(.+)$/.exec(line); current = { - path: match ? normalizeDiffPath(match[1] ?? "") : "", + // 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; } @@ -53,25 +159,35 @@ export function parseTurnDiffFilesFromUnifiedDiff( continue; } - if (line.startsWith("rename to ")) { - current.path = normalizeDiffPath(line.slice("rename to ".length)); + if (current.inHeader && line.startsWith("rename to ")) { + const nextPath = readGitPathToken(line.slice("rename to ".length)); + if (nextPath) { + current.path = normalizeDiffPath(nextPath); + } continue; } - if (line.startsWith("+++ ")) { - const nextPath = line.slice(4).trim(); - if (nextPath !== "/dev/null") { + 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("+++")) { + 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("-") && !line.startsWith("---")) { + if (line.startsWith("-") && !(current.inHeader && line.startsWith("--- "))) { current.deletions += 1; } } diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 43963026..165662ed 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -831,7 +831,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => + it.effect("refreshes each tracked upstream ref even when siblings share one remote", () => Effect.gen(function* () { const ok = (stdout = "") => Effect.succeed({ @@ -863,14 +863,26 @@ it.layer(TestLayer)("git integration", (it) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { fetchCount += 1; expect(input.cwd).toBe("/repo"); - expect(input.args).toEqual([ - "--git-dir", - "/repo/.git", - "fetch", - "--quiet", - "--no-tags", - "origin", - ]); + 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") { @@ -901,84 +913,80 @@ 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 on one remote", - () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); + it.effect("briefly backs off failed refreshes per tracked upstream ref on one remote", () => + Effect.gen(function* () { + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - 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"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - if (input.operation === "GitCore.statusDetails.status") { - 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" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } + let fetchCount = 0; + const core = yield* makeIsolatedGitCore((input) => { + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + 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"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; return Effect.fail( new GitCommandError({ operation: input.operation, command: `git ${input.args.join(" ")}`, cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", + detail: "simulated fetch timeout", }), ); - }); + } + if (input.operation === "GitCore.statusDetails.status") { + 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" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "Unexpected git command in refresh failure cooldown test.", + }), + ); + }); - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(2); + }), ); it.effect("throws when branch does not exist", () => @@ -1076,6 +1084,7 @@ it.layer(TestLayer)("git integration", (it) => { "--quiet", "--no-tags", remoteName, + `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, ]); }), ); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index c00e4c6f..a83142ce 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -81,7 +81,13 @@ type TraceTailState = { 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; }> {} interface ExecuteGitOptions { @@ -949,14 +955,19 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const fetchRemoteForStatus = ( gitCommonDir: string, - remoteName: 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.fetchRemoteForStatus", fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { allowNonZeroExit: true, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), @@ -976,7 +987,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( cacheKey: StatusRemoteRefreshCacheKey, ) { - yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); + yield* fetchRemoteForStatus(cacheKey.gitCommonDir, { + upstreamRef: cacheKey.upstreamRef, + remoteName: cacheKey.remoteName, + upstreamBranch: cacheKey.upstreamBranch, + }); return true as const; }); @@ -1006,7 +1021,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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, }), ); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3bfa65d0..f9feba00 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -141,6 +141,7 @@ 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()), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 06dca921..55d961ba 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -582,14 +582,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (routed.isActive) { yield* routed.adapter.stopSession(routed.threadId); } - yield* directory.upsert({ - threadId: input.threadId, - provider: routed.adapter.provider, - status: "stopped", - runtimePayload: { - activeTurnId: null, - }, - }); + // 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 09dc6a36..a66a9c0f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -122,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 27bd8517..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) => @@ -140,6 +147,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + remove, getProvider, getBinding, listThreadIds, diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f74d11af..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; From 12cc3413ec6c11e840334a3df972e339ce756bfd Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:59:46 -0400 Subject: [PATCH 29/36] fix(server): boot cli without top-level await --- apps/server/src/bin.ts | 30 +++++++++++++++++++++--------- docs/upstream-sync-log.md | 2 +- turbo.json | 6 +----- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index a3d02ac7..7823a808 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -10,16 +10,28 @@ const isBunRuntime = typeof Bun !== "undefined"; const main = (Command.run(cli, { version }) as any).pipe(Effect.scoped); -if (isBunRuntime) { - const BunRuntime = await import("@effect/platform-bun/BunRuntime"); - const BunServices = await import("@effect/platform-bun/BunServices"); - const cliRuntimeLayer = Layer.mergeAll(BunServices.layer, NetService.layer); - - BunRuntime.runMain(main.pipe(Effect.provide(cliRuntimeLayer))); -} else { - const NodeRuntime = await import("@effect/platform-node/NodeRuntime"); - const NodeServices = await import("@effect/platform-node/NodeServices"); +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/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index ce133157..717cde53 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -16,7 +16,7 @@ - `37965da0` `fix(server): handle OpenCode text response format in commit message generation (#2202)` — `SKIP`: current sync branch does not carry the upstream OpenCode text-generation layer shape this fix targets, so importing it would reintroduce divergent provider code. - `8dbcf92a` `fix(server): prevent probeClaudeCapabilities from wasting API requests (#2192)` — `ADAPT`: kept Kodo's subscription-only Claude probe but switched it to the no-prompt initialization pattern so it no longer burns Anthropic requests. - `66c326b8` `Redesign model picker with favorites and search (#2153)` — `SKIP`: broad model-picker UX rewrite conflicts with Kodo-specific composer and settings behavior. -- `3b98fe35` ``effect-codex-app-server` (#1942)` — `MANUAL REVIEW`: very large Codex runtime/package refactor across server, provider layers, and generated schema surfaces. +- `3b98fe35` ``effect-codex-app-server` (#1942)`—`MANUAL REVIEW`: very large Codex runtime/package refactor across server, provider layers, and generated schema surfaces. - `306ec4bb` `Refactor OpenCode lifecycle and structured output handling (#2218)` — `MANUAL REVIEW`: wide provider/runtime rewrite spanning server and web integration, too large for this bounded sync. - `6d1505c9` `fix: Change right panel sheet to be below title bar / action bar (#2224)` — `SELECTIVE FRONTEND`: localized layout correction on an existing surface, but deferred pending visual review against Kodo's desktop/title-bar divergence. - `de05b0c9` `fix(web): restore manual sort drag and keep per-group expand state (#2221)` — `MANUAL REVIEW`: touches sidebar grouping and project/worktree state behavior that Kodo intentionally owns. diff --git a/turbo.json b/turbo.json index 96fee3ef..0db5cef3 100644 --- a/turbo.json +++ b/turbo.json @@ -23,11 +23,7 @@ "T3CODE_OTLP_EXPORT_INTERVAL_MS", "T3CODE_OTLP_SERVICE_NAME" ], - "globalPassThroughEnv": [ - "KODOCODE_DEV_RUNNER_PID", - "KODOCODE_DEV_SHUTDOWN_SIGNAL", - "PATHEXT" - ], + "globalPassThroughEnv": ["KODOCODE_DEV_RUNNER_PID", "KODOCODE_DEV_SHUTDOWN_SIGNAL", "PATHEXT"], "tasks": { "build": { "dependsOn": ["^build"], From 290c92c61c9dd778178da7332a5ebddc84a33dea Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:59:56 -0400 Subject: [PATCH 30/36] feat(web): add fullscreen project picker preference --- .../src/components/ProjectFolderBrowser.tsx | 152 ++++++++++++++ .../components/ProjectFolderPickerDialog.tsx | 112 ++++++++++ apps/web/src/components/Sidebar.tsx | 196 +++++++++--------- .../settings/SettingsAppearancePanel.tsx | 44 ++++ .../settings/SettingsGeneralPanel.tsx | 4 + apps/web/src/hooks/useSettings.test.ts | 15 ++ apps/web/src/hooks/useSettings.ts | 5 + packages/contracts/src/settings.ts | 7 + 8 files changed, 433 insertions(+), 102 deletions(-) create mode 100644 apps/web/src/components/ProjectFolderBrowser.tsx create mode 100644 apps/web/src/components/ProjectFolderPickerDialog.tsx 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 eb612d25..90b2ff82 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -60,7 +60,6 @@ import { APP_BASE_NAME, APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { cn, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { - canNavigateUp, ensureBrowseDirectoryPath, findProjectByPath, getBrowseParentPath, @@ -135,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"; @@ -744,6 +745,7 @@ export default function Sidebar() { 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(""); @@ -773,7 +775,9 @@ export default function Sidebar() { const shouldShowSidebarWordmark = true; const shouldShowSidebarLogo = true; const platform = navigator.platform; - const shouldShowProjectPathEntry = addingProject; + const usesFullscreenProjectPicker = isElectron && appSettings.projectPickerMode === "fullscreen"; + const shouldShowProjectPathEntry = addingProject && !usesFullscreenProjectPicker; + const isProjectPickerOpen = addingProject || projectPickerDialogOpen; const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -873,6 +877,8 @@ 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); @@ -882,6 +888,7 @@ export default function Sidebar() { setBrowseError(null); setIsBrowsingFilesystem(false); setAddingProject(false); + setProjectPickerDialogOpen(false); }; const existing = findProjectByPath(projects, cwd); @@ -975,6 +982,20 @@ export default function Sidebar() { 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; @@ -994,17 +1015,16 @@ export default function Sidebar() { ); const handleStartAddProject = () => { - if (addingProject) { - setAddingProject(false); - setAddProjectError(null); - setBrowsePanelOpen(false); - setBrowseError(null); - setBrowseEntries([]); - setBrowseCurrentDirectory(null); - setBrowsePath(""); + if (isProjectPickerOpen) { + closeProjectPicker(); return; } setAddProjectError(null); + if (usesFullscreenProjectPicker) { + setProjectPickerDialogOpen(true); + openBrowsePanel(); + return; + } setAddingProject(true); if (isElectron) { openBrowsePanel(); @@ -2309,10 +2329,8 @@ export default function Sidebar() { render={
@@ -2349,9 +2367,7 @@ export default function Sidebar() { onKeyDown={(event) => { if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - setBrowsePanelOpen(false); + closeProjectPicker(); } }} autoFocus @@ -2383,88 +2399,27 @@ export default function Sidebar() { {browsePanelOpen && ( -
-
- - { - setBrowsePath(event.target.value); - setBrowseError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - void browseFilesystem(browsePath); - } - }} - /> - -
- {browseCurrentDirectory && ( - - )} -
- {browseEntries.length === 0 ? ( -

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

- ) : ( - browseEntries.map((entry) => ( - - )) - )} -
- {browseError && ( -

- {browseError} -

- )} -
+ { + setBrowsePath(nextPath); + setBrowseError(null); + }} + onBrowse={() => void browseFilesystem(browsePath)} + onBrowseUp={handleBrowseUp} + onBrowseEntryOpen={handleBrowseEntryOpen} + onAddCurrentDirectory={() => { + if (browseCurrentDirectory) { + void addProjectFromPath(browseCurrentDirectory); + } + }} + /> )} {addProjectError && (

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

No projects yet
@@ -2533,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/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.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -95,6 +98,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffWordWrap, settings.enableAssistantStreaming, 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..930c61c1 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,14 @@ describe("buildLegacyClientSettingsMigrationPatch", () => { confirmThreadDelete: false, }); }); + + it("migrates the project picker preference from legacy local settings", () => { + expect( + buildLegacyClientSettingsMigrationPatch({ + projectPickerMode: "sidebar", + }), + ).toEqual({ + projectPickerMode: "sidebar", + }); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index dbcd3118..3ae31dc8 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,10 @@ export function buildLegacyClientSettingsMigrationPatch( patch.diffWordWrap = legacySettings.diffWordWrap; } + if (Schema.is(ProjectPickerMode)(legacySettings.projectPickerMode)) { + patch.projectPickerMode = legacySettings.projectPickerMode; + } + if (Schema.is(SidebarProjectSortOrder)(legacySettings.sidebarProjectSortOrder)) { patch.sidebarProjectSortOrder = legacySettings.sidebarProjectSortOrder; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90be6c79..4896bc88 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -21,6 +21,10 @@ 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 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 +37,9 @@ 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)), + projectPickerMode: ProjectPickerMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROJECT_PICKER_MODE), + ), promptEnhancePreset: PromptEnhancePreset.pipe( Schema.withDecodingDefault(() => "balanced" as const satisfies typeof PromptEnhancePreset.Type), ), From 69bd860490998607449e1f3932e70addeec91701 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:38:27 -0400 Subject: [PATCH 31/36] feat(web): finish model picker redesign --- apps/server/src/keybindings.test.ts | 3 + apps/server/src/keybindings.ts | 8 + apps/web/src/components/AppSidebarLayout.tsx | 26 + apps/web/src/components/ChatView.tsx | 18 + apps/web/src/components/chat/ModelListRow.tsx | 107 ++++ .../components/chat/ModelPickerContent.tsx | 528 ++++++++++++++++++ .../components/chat/ModelPickerSidebar.tsx | 166 ++++++ .../chat/ProviderModelPicker.browser.tsx | 272 +++------ .../components/chat/ProviderModelPicker.tsx | 309 ++++------ .../chat/modelPickerModelHighlights.ts | 11 + .../components/chat/modelPickerSearch.test.ts | 85 +++ .../src/components/chat/modelPickerSearch.ts | 108 ++++ .../src/components/chat/providerIconUtils.ts | 56 ++ .../settings/SettingsGeneralPanel.tsx | 2 + apps/web/src/hooks/useSettings.test.ts | 18 + apps/web/src/hooks/useSettings.ts | 21 + apps/web/src/index.css | 21 + apps/web/src/keybindings.test.ts | 67 +++ apps/web/src/keybindings.ts | 17 + apps/web/src/modelPickerOpenState.ts | 17 + apps/web/src/shortcutModifierState.ts | 88 +++ packages/contracts/src/keybindings.test.ts | 29 +- packages/contracts/src/keybindings.ts | 21 + packages/contracts/src/settings.ts | 7 + 24 files changed, 1618 insertions(+), 387 deletions(-) create mode 100644 apps/web/src/components/chat/ModelListRow.tsx create mode 100644 apps/web/src/components/chat/ModelPickerContent.tsx create mode 100644 apps/web/src/components/chat/ModelPickerSidebar.tsx create mode 100644 apps/web/src/components/chat/modelPickerModelHighlights.ts create mode 100644 apps/web/src/components/chat/modelPickerSearch.test.ts create mode 100644 apps/web/src/components/chat/modelPickerSearch.ts create mode 100644 apps/web/src/components/chat/providerIconUtils.ts create mode 100644 apps/web/src/modelPickerOpenState.ts create mode 100644 apps/web/src/shortcutModifierState.ts 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..d1a4a49c 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, @@ -69,6 +70,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 +79,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 { 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.tsx b/apps/web/src/components/ChatView.tsx index 67d81e66..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" || @@ -3353,6 +3366,7 @@ export default function ChatView({ threadId }: ChatViewProps) { 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/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/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index d698205e..280e5322 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,5 +1,9 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { page } from "vitest/browser"; +import { + type ProviderKind, + type ResolvedKeybindingsConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { page, userEvent } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -110,24 +114,12 @@ const TEST_PROVIDERS: 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/SettingsGeneralPanel.tsx b/apps/web/src/components/settings/SettingsGeneralPanel.tsx index 187099bb..a75413f2 100644 --- a/apps/web/src/components/settings/SettingsGeneralPanel.tsx +++ b/apps/web/src/components/settings/SettingsGeneralPanel.tsx @@ -53,6 +53,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.projectPickerMode !== DEFAULT_UNIFIED_SETTINGS.projectPickerMode ? ["Project picker"] : []), + ...(settings.favorites.length > 0 ? ["Favorite models"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -97,6 +98,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, + settings.favorites.length, settings.planModelSelection, settings.projectPickerMode, settings.promptEnhancePreset, diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 930c61c1..4b8c2928 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -28,4 +28,22 @@ describe("buildLegacyClientSettingsMigrationPatch", () => { 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 3ae31dc8..e0d3c17b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -203,6 +203,27 @@ 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; } 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 dedc7e85..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", () => { @@ -169,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( @@ -307,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", () => { @@ -362,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/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/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/settings.ts b/packages/contracts/src/settings.ts index 4896bc88..3a72da54 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -25,6 +25,12 @@ 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"; @@ -37,6 +43,7 @@ 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), ), From 24da89e0c4ef14ef32fe7c852af9475c9b401d53 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Tue, 21 Apr 2026 18:12:12 -0400 Subject: [PATCH 32/36] docs(sync): record 2026-04-21 upstream review --- docs/upstream-sync-log.md | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 717cde53..f4e5d5ef 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,5 +1,50 @@ # Upstream Sync Log +## 2026-04-21 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@69bd860490998607449e1f3932e70addeec91701` +- Upstream range reviewed: `pingdotgg/t3code@f6978db60553716a9974b9e85f855bae8124905d..3a1daa87ac103da0c176426e82eb576d87046bdf` +- Upstream release window: `v0.0.21-nightly.20260421.88` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 + +### Classification + +- `40b3a800` `fix(server): trim OpenCode provider model names (#2252)` — `SKIP`: current sync branch does not carry the upstream OpenCode provider layer this fix targets. +- `055897f0` `fix: enforce opencode >= 1.14.19 and reveal window on Wayland (#2262)` — `SKIP`: mixed upstream-only OpenCode provider work plus desktop window-reveal behavior that diverges from Kodo's desktop runtime. +- `3a1daa87` `Add close buttons to toasts (#2023)` — `MANUAL REVIEW`: broad cross-surface web change touching Sidebar, command palette, settings, chat, and shared toast primitives, so it exceeds this run's selective-frontend budget. + +### Applied changes + +- None. No upstream commit in this reviewed window met the bounded sync bar for direct backend/runtime/tooling import into Kodo. + +### Adapted changes + +- None. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- `3a1daa87` Toast close buttons and toast-surface reshaping: potentially useful, but too broad and entangled with Kodo-owned web surfaces for this run. + +### Skipped changes + +- `40b3a800` OpenCode provider model-name trimming: inapplicable without the upstream OpenCode provider layer. +- `055897f0` OpenCode minimum-version enforcement plus Wayland reveal behavior: upstream provider/runtime divergence and desktop behavior divergence. + +### Deferred selective frontend candidates + +- None. + +### Checks + +- `bun fmt` ✅ +- `bun lint` ✅ +- `bun typecheck` ✅ + ## 2026-04-20 - Fork branch: `sync/upstream-2026-04-17` From 6f5f4f67b4d167825c15bf28d5c9855101c9886f Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Wed, 22 Apr 2026 09:12:07 -0400 Subject: [PATCH 33/36] sync: port upstream provider probe fixes --- apps/server/src/codexAppServerManager.test.ts | 14 ++++++ .../src/provider/Layers/ClaudeProvider.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 34 +++++++++++++- apps/server/src/provider/codexAccount.ts | 5 ++- apps/server/src/provider/providerSnapshot.ts | 1 + docs/upstream-sync-log.md | 44 +++++++++++++++++++ 6 files changed, 98 insertions(+), 3 deletions(-) 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/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 4f5bb666..b62d21e3 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -12,6 +12,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, DEFAULT_TIMEOUT_MS, detailFromResult, @@ -508,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, ); 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/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/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/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index f4e5d5ef..601c8a4b 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,5 +1,49 @@ # Upstream Sync Log +## 2026-04-22 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@24da89e0c4ef14ef32fe7c852af9475c9b401d53` +- Upstream range reviewed: `pingdotgg/t3code@3a1daa87ac103da0c176426e82eb576d87046bdf..b8305afa29309e52045987caab91db9b7e481ac0` +- Upstream release window: `v0.0.21-nightly.20260421.88..v0.0.21-nightly.20260422.92` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 + +### Classification + +- `b7c89cf4` `Refresh Codex protocol bindings to \`be75785504ff152fa6333e380a2d50642f42fba0\` (#2276)`—`ADAPT`: Kodo does not vendor the upstream generated binding package, so this run ported only the compatible Codex account-plan labeling changes and deferred the protocol/codegen refresh itself. +- `b8305afa` `fix: increase Claude auth probe timeout to 10s (#2272)` — `APPLY` + +### Applied changes + +- `b8305afa` Increased the shared Claude auth probe timeout from 4s to 10s so first-run `claude auth status` checks stop flapping on slower disks and Windows environments. + +### Adapted changes + +- `b7c89cf4` Extended Kodo's Codex account parsing to recognize `prolite`, relabeled `pro` as `ChatGPT Pro 20x Subscription`, and surfaced `prolite` as `ChatGPT Pro 5x Subscription` without pulling in upstream protocol/codegen files that do not exist in this repo. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- None. + +### Skipped changes + +- None. + +### Deferred selective frontend candidates + +- None. + +### Checks + +- `bun install` ✅ +- `bun fmt` ✅ +- `bun lint` ✅ (with two pre-existing warnings in [`apps/server/src/os-jank.ts`](/mnt/c/Users/Admin/.codex/worktrees/0b86/KodoCode/apps/server/src/os-jank.ts) and [`apps/server/src/persistence/Layers/ProjectionThreads.ts`](/mnt/c/Users/Admin/.codex/worktrees/0b86/KodoCode/apps/server/src/persistence/Layers/ProjectionThreads.ts)) +- `bun typecheck` ✅ + ## 2026-04-21 - Fork branch: `sync/upstream-2026-04-17` From 40cd204092a3de25d5592de74cf99c689d8fa6d7 Mon Sep 17 00:00:00 2001 From: boggedbrush Date: Thu, 23 Apr 2026 09:13:23 -0400 Subject: [PATCH 34/36] sync: port upstream runtime hardening Upstream-Ref: pingdotgg/t3code@e25db3a5 Upstream-Ref: pingdotgg/t3code@aa2d385a Upstream-Ref: pingdotgg/t3code@fd3b96b4 Upstream-Ref: pingdotgg/t3code@b0b7b38d Adapted-by: ported atomic config writes and remaining CODEX_HOME expansion paths without importing upstream-only provider cache/runtime modules --- apps/server/src/atomicWrite.ts | 25 ++++++++++ apps/server/src/keybindings.ts | 21 ++++---- apps/server/src/processRunner.test.ts | 20 +++++++- apps/server/src/processRunner.ts | 15 +++++- .../Layers/ProjectFaviconResolver.test.ts | 13 +++++ .../project/Layers/ProjectFaviconResolver.ts | 1 + .../src/provider/Layers/CodexProvider.ts | 1 + .../usage/modules/codexUsageModule.ts | 5 +- apps/server/src/serverSettings.ts | 13 ++--- docs/upstream-sync-log.md | 50 +++++++++++++++++++ 10 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 apps/server/src/atomicWrite.ts 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/keybindings.ts b/apps/server/src/keybindings.ts index d1a4a49c..ab3393a7 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -45,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"; @@ -675,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({ @@ -693,7 +697,6 @@ const makeKeybindings = Effect.gen(function* () { }), ), ); - }; const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( Effect.map(({ keybindings, issues }) => ({ 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/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 8d4502d4..834c6ef8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -269,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"); 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/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/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 601c8a4b..5657b656 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,5 +1,55 @@ # Upstream Sync Log +## 2026-04-23 + +- Fork branch: `sync/upstream-2026-04-17` +- Fork base: `boggedbrush/KodoCode@6f5f4f67b4d167825c15bf28d5c9855101c9886f` +- Upstream range reviewed: `pingdotgg/t3code@b8305afa29309e52045987caab91db9b7e481ac0..b0b7b38da1dc4b19833d13f84eb907b1e2adfb63` +- Upstream release window: `v0.0.21-nightly.20260422.92..v0.0.21-nightly.20260423.101` +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 + +### Classification + +- `e25db3a5` `Fix provider cache atomic write temp path collisions (#2291)` — `ADAPT`: Kodo does not carry the upstream provider status cache file or runtime-state module, so this run ported the shared atomic-write hardening only into the colliding config persistence paths Kodo still owns. +- `aa2d385a` `fix(server): restore CODEX_HOME tilde expansion for Codex launches (#2255)` — `ADAPT`: Kodo had already expanded `CODEX_HOME` in several Codex launch paths, so this run patched the remaining equivalent config-read and login-status paths rather than cherry-picking the upstream files directly. +- `fd3b96b4` `Add IntelliJ project icon to the list of possible favicon paths (#1651)` — `APPLY` +- `b0b7b38d` `fix(server): detect localized Windows command errors (#2152)` — `APPLY` + +### Applied changes + +- `fd3b96b4` Added `.idea/icon.svg` to the project favicon resolver so IntelliJ-based repos can surface a project icon without extra HTML metadata. +- `b0b7b38d` Expanded Windows command-not-found detection in `processRunner` to recognize localized `cmd.exe` error output instead of only the English message. + +### Adapted changes + +- `e25db3a5` Added [`apps/server/src/atomicWrite.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/atomicWrite.ts) and rewired [`apps/server/src/keybindings.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/keybindings.ts) and [`apps/server/src/serverSettings.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/serverSettings.ts) to use temp directories plus UUID-backed temp files, avoiding timestamp collision races during concurrent writes. +- `aa2d385a` Updated [`apps/server/src/provider/Layers/CodexProvider.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/provider/Layers/CodexProvider.ts) so `~/...` Codex home paths are expanded before reading `config.toml`. +- `aa2d385a` follow-up adaptation: updated [`apps/server/src/provider/usage/modules/codexUsageModule.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/provider/usage/modules/codexUsageModule.ts) so `codex login status` inherits expanded `CODEX_HOME` values too. + +### Selective frontend changes ported + +- None. + +### Manual-review candidates + +- None. + +### Skipped changes + +- None. + +### Deferred selective frontend candidates + +- None. + +### Checks + +- `bun install` ✅ +- `bun fmt` ✅ +- `bun lint` ✅ (with the same two pre-existing warnings in [`apps/server/src/os-jank.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/os-jank.ts) and [`apps/server/src/persistence/Layers/ProjectionThreads.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/persistence/Layers/ProjectionThreads.ts)) +- `bun typecheck` ✅ +- `cd apps/server && bun run test src/processRunner.test.ts src/project/Layers/ProjectFaviconResolver.test.ts` ✅ + ## 2026-04-22 - Fork branch: `sync/upstream-2026-04-17` From b2dce34eb6ce41d9de17d650d46bb8adea83bcee Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:54:37 -0400 Subject: [PATCH 35/36] docs(sync): record 2026-04-24 upstream review --- docs/upstream-sync-log.md | 402 +++----------------------------------- 1 file changed, 26 insertions(+), 376 deletions(-) diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index 5657b656..bf8b79f7 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -1,404 +1,54 @@ # Upstream Sync Log -## 2026-04-23 +## 2026-04-24 -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@6f5f4f67b4d167825c15bf28d5c9855101c9886f` -- Upstream range reviewed: `pingdotgg/t3code@b8305afa29309e52045987caab91db9b7e481ac0..b0b7b38da1dc4b19833d13f84eb907b1e2adfb63` -- Upstream release window: `v0.0.21-nightly.20260422.92..v0.0.21-nightly.20260423.101` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 +- 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/8 ### Classification -- `e25db3a5` `Fix provider cache atomic write temp path collisions (#2291)` — `ADAPT`: Kodo does not carry the upstream provider status cache file or runtime-state module, so this run ported the shared atomic-write hardening only into the colliding config persistence paths Kodo still owns. -- `aa2d385a` `fix(server): restore CODEX_HOME tilde expansion for Codex launches (#2255)` — `ADAPT`: Kodo had already expanded `CODEX_HOME` in several Codex launch paths, so this run patched the remaining equivalent config-read and login-status paths rather than cherry-picking the upstream files directly. -- `fd3b96b4` `Add IntelliJ project icon to the list of possible favicon paths (#1651)` — `APPLY` -- `b0b7b38d` `fix(server): detect localized Windows command errors (#2152)` — `APPLY` +- `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 -- `fd3b96b4` Added `.idea/icon.svg` to the project favicon resolver so IntelliJ-based repos can surface a project icon without extra HTML metadata. -- `b0b7b38d` Expanded Windows command-not-found detection in `processRunner` to recognize localized `cmd.exe` error output instead of only the English message. +- 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 -- `e25db3a5` Added [`apps/server/src/atomicWrite.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/atomicWrite.ts) and rewired [`apps/server/src/keybindings.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/keybindings.ts) and [`apps/server/src/serverSettings.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/serverSettings.ts) to use temp directories plus UUID-backed temp files, avoiding timestamp collision races during concurrent writes. -- `aa2d385a` Updated [`apps/server/src/provider/Layers/CodexProvider.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/provider/Layers/CodexProvider.ts) so `~/...` Codex home paths are expanded before reading `config.toml`. -- `aa2d385a` follow-up adaptation: updated [`apps/server/src/provider/usage/modules/codexUsageModule.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/provider/usage/modules/codexUsageModule.ts) so `codex login status` inherits expanded `CODEX_HOME` values too. - -### Selective frontend changes ported - -- None. - -### Manual-review candidates - -- None. - -### Skipped changes - -- None. - -### Deferred selective frontend candidates - - None. -### Checks - -- `bun install` ✅ -- `bun fmt` ✅ -- `bun lint` ✅ (with the same two pre-existing warnings in [`apps/server/src/os-jank.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/os-jank.ts) and [`apps/server/src/persistence/Layers/ProjectionThreads.ts`](/mnt/c/Users/Admin/.codex/worktrees/431b/KodoCode/apps/server/src/persistence/Layers/ProjectionThreads.ts)) -- `bun typecheck` ✅ -- `cd apps/server && bun run test src/processRunner.test.ts src/project/Layers/ProjectFaviconResolver.test.ts` ✅ - -## 2026-04-22 - -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@24da89e0c4ef14ef32fe7c852af9475c9b401d53` -- Upstream range reviewed: `pingdotgg/t3code@3a1daa87ac103da0c176426e82eb576d87046bdf..b8305afa29309e52045987caab91db9b7e481ac0` -- Upstream release window: `v0.0.21-nightly.20260421.88..v0.0.21-nightly.20260422.92` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 - -### Classification - -- `b7c89cf4` `Refresh Codex protocol bindings to \`be75785504ff152fa6333e380a2d50642f42fba0\` (#2276)`—`ADAPT`: Kodo does not vendor the upstream generated binding package, so this run ported only the compatible Codex account-plan labeling changes and deferred the protocol/codegen refresh itself. -- `b8305afa` `fix: increase Claude auth probe timeout to 10s (#2272)` — `APPLY` - -### Applied changes - -- `b8305afa` Increased the shared Claude auth probe timeout from 4s to 10s so first-run `claude auth status` checks stop flapping on slower disks and Windows environments. - -### Adapted changes - -- `b7c89cf4` Extended Kodo's Codex account parsing to recognize `prolite`, relabeled `pro` as `ChatGPT Pro 20x Subscription`, and surfaced `prolite` as `ChatGPT Pro 5x Subscription` without pulling in upstream protocol/codegen files that do not exist in this repo. - ### Selective frontend changes ported - None. ### Manual-review candidates -- None. +- `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 -- None. - -### Deferred selective frontend candidates - -- None. +- `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 install` ✅ -- `bun fmt` ✅ -- `bun lint` ✅ (with two pre-existing warnings in [`apps/server/src/os-jank.ts`](/mnt/c/Users/Admin/.codex/worktrees/0b86/KodoCode/apps/server/src/os-jank.ts) and [`apps/server/src/persistence/Layers/ProjectionThreads.ts`](/mnt/c/Users/Admin/.codex/worktrees/0b86/KodoCode/apps/server/src/persistence/Layers/ProjectionThreads.ts)) -- `bun typecheck` ✅ - -## 2026-04-21 - -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@69bd860490998607449e1f3932e70addeec91701` -- Upstream range reviewed: `pingdotgg/t3code@f6978db60553716a9974b9e85f855bae8124905d..3a1daa87ac103da0c176426e82eb576d87046bdf` -- Upstream release window: `v0.0.21-nightly.20260421.88` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 - -### Classification - -- `40b3a800` `fix(server): trim OpenCode provider model names (#2252)` — `SKIP`: current sync branch does not carry the upstream OpenCode provider layer this fix targets. -- `055897f0` `fix: enforce opencode >= 1.14.19 and reveal window on Wayland (#2262)` — `SKIP`: mixed upstream-only OpenCode provider work plus desktop window-reveal behavior that diverges from Kodo's desktop runtime. -- `3a1daa87` `Add close buttons to toasts (#2023)` — `MANUAL REVIEW`: broad cross-surface web change touching Sidebar, command palette, settings, chat, and shared toast primitives, so it exceeds this run's selective-frontend budget. - -### Applied changes - -- None. No upstream commit in this reviewed window met the bounded sync bar for direct backend/runtime/tooling import into Kodo. - -### Adapted changes - -- None. - -### Selective frontend changes ported - -- None. - -### Manual-review candidates - -- `3a1daa87` Toast close buttons and toast-surface reshaping: potentially useful, but too broad and entangled with Kodo-owned web surfaces for this run. - -### Skipped changes - -- `40b3a800` OpenCode provider model-name trimming: inapplicable without the upstream OpenCode provider layer. -- `055897f0` OpenCode minimum-version enforcement plus Wayland reveal behavior: upstream provider/runtime divergence and desktop behavior divergence. - -### Deferred selective frontend candidates - -- None. - -### Checks - -- `bun fmt` ✅ -- `bun lint` ✅ -- `bun typecheck` ✅ - -## 2026-04-20 - -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@f7c93dba7c10a045cb07a097a467fba88597b711` -- Upstream range reviewed: `pingdotgg/t3code@9df3c640210fecccb58f7fbc735f81ca0ee011bd..f6978db60553716a9974b9e85f855bae8124905d` -- Upstream release window: `v0.0.21-nightly.20260420.77` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 - -### Classification - -- `c83bc5d4` `fix(release): use v tag format for nightly releases (#2186)` — `SKIP`: release workflow divergence. -- `20f346d8` `Expand leading ~ in Codex home paths before exporting CODEX_HOME (#2210)` — `ADAPT`: ported into Kodo's current Codex runtime files and consolidated duplicate `~` path expansion helpers behind a shared server utility. -- `57b59b5b` `Devcontainer / IDE updates (#2208)` — `SKIP`: devcontainer/editor housekeeping outside sync priorities. -- `37965da0` `fix(server): handle OpenCode text response format in commit message generation (#2202)` — `SKIP`: current sync branch does not carry the upstream OpenCode text-generation layer shape this fix targets, so importing it would reintroduce divergent provider code. -- `8dbcf92a` `fix(server): prevent probeClaudeCapabilities from wasting API requests (#2192)` — `ADAPT`: kept Kodo's subscription-only Claude probe but switched it to the no-prompt initialization pattern so it no longer burns Anthropic requests. -- `66c326b8` `Redesign model picker with favorites and search (#2153)` — `SKIP`: broad model-picker UX rewrite conflicts with Kodo-specific composer and settings behavior. -- `3b98fe35` ``effect-codex-app-server` (#1942)`—`MANUAL REVIEW`: very large Codex runtime/package refactor across server, provider layers, and generated schema surfaces. -- `306ec4bb` `Refactor OpenCode lifecycle and structured output handling (#2218)` — `MANUAL REVIEW`: wide provider/runtime rewrite spanning server and web integration, too large for this bounded sync. -- `6d1505c9` `fix: Change right panel sheet to be below title bar / action bar (#2224)` — `SELECTIVE FRONTEND`: localized layout correction on an existing surface, but deferred pending visual review against Kodo's desktop/title-bar divergence. -- `de05b0c9` `fix(web): restore manual sort drag and keep per-group expand state (#2221)` — `MANUAL REVIEW`: touches sidebar grouping and project/worktree state behavior that Kodo intentionally owns. -- `f6978db6` `chore(turbo): pass through PATHEXT (#2184)` — `APPLY` - -### Applied changes - -- `f6978db6` Added `PATHEXT` to Turbo passthrough env so Windows command resolution survives task execution without dropping Kodo's existing dev-runner passthrough vars. - -### Adapted changes +- `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. -- `20f346d8` Added [`apps/server/src/pathExpansion.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/pathExpansion.ts) and [`apps/server/src/pathExpansion.test.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/pathExpansion.test.ts), then wired Codex session startup, Codex CLI version checks, Codex git text generation, and Codex provider probes through expanded `CODEX_HOME` values. -- `20f346d8` follow-up cleanup: repointed [`apps/server/src/os-jank.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/os-jank.ts) and [`apps/server/src/workspace/Layers/WorkspacePaths.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/workspace/Layers/WorkspacePaths.ts) at the same helper to avoid carrying three separate `~` expansion implementations. -- `8dbcf92a` Updated [`apps/server/src/provider/Layers/ClaudeProvider.ts`](/mnt/c/Users/Admin/.codex/worktrees/9b98/KodoCode/apps/server/src/provider/Layers/ClaudeProvider.ts) so the Claude capability probe uses a never-yielding async prompt and abort-driven initialization instead of a real prompt/API request. - -### Selective frontend changes ported - -- None. - -### Manual-review candidates - -- `3b98fe35` `effect-codex-app-server`: large server/provider/generated-schema refactor that needs its own focused sync. -- `306ec4bb` OpenCode lifecycle and structured output handling: large provider/runtime rewrite beyond this run's budget. -- `de05b0c9` Sidebar manual-sort and per-group expand-state repair: mixed workflow/state change that needs Kodo product review. - -### Skipped changes - -- Release / packaging / dev-only changes: `c83bc5d4`, `57b59b5b` -- Upstream-only or currently inapplicable provider-layer fix: `37965da0` -- Kodo-divergent product-surface rewrite: `66c326b8` - -### Deferred selective frontend candidates - -- `6d1505c9` Right panel sheet below title bar/action bar: deferred because Kodo's desktop/title-bar behavior already diverges; likely safe for a future PR after visual review. - -### Checks - -- `bun fmt` ⚠️ not runnable in this automation environment because `bun` is not installed. -- `bun lint` ⚠️ not runnable in this automation environment because `bun` is not installed. -- `bun typecheck` ⚠️ not runnable in this automation environment because `bun` is not installed. -- Focused runtime validation was also blocked because `node` is not installed in this automation environment. - -## 2026-04-19 - -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@adf75c663ced736d0807242a041f67dc699aecad` -- Upstream range reviewed: `pingdotgg/t3code@9df3c640210fecccb58f7fbc735f81ca0ee011bd..9df3c640210fecccb58f7fbc735f81ca0ee011bd` -- Upstream release window: `main unchanged since 2026-04-18 review` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 - -### Classification - -- No new upstream `main` commits were available after `9df3c640`, so there were no new `APPLY`, `ADAPT`, `SELECTIVE FRONTEND`, `MANUAL REVIEW`, or `SKIP` classifications in this run. -- User-requested follow-up: `44afe784` `Add filesystem browse API and command palette project picker (#2024)` — `ADAPT`: ported the backend filesystem browse API and a focused Sidebar folder browser without importing upstream command-palette or project-flow churn. - -### Applied changes - -- None. Upstream `main` had no new commits beyond the previously reviewed boundary. -- Local branch follow-up: repaired sync-branch typecheck drift caused by Effect cache API and branded-id helper changes so required validation passes cleanly. -- Added `filesystem.browse` contracts/RPC wiring and server-side directory browsing through `WorkspaceEntries`. -- Added a Kodo-specific inline folder browser to the Sidebar add-project flow so desktop users are no longer forced through the system folder picker. - -### Adapted changes - -- `44afe784` selectively adapted: -- kept the browse backend/API pieces -- omitted the upstream command palette and project picker architecture -- wired the feature into Kodo's existing Sidebar add-project UI instead - -### Selective frontend changes ported - -- None. - -### Manual-review candidates - -- `8dba2d64` Node-native TypeScript adoption: still too broad for a bounded sync. -- `a7a44d06` Windows PATH hydration/repair: still valuable, but remains a large shared-runtime adaptation. -- `40009735` Backend startup readiness extraction: still overlaps Kodo desktop startup policy. -- `4e0c003e` Non-empty project deletion flow: still a mixed server/client workflow needing product review. - -### Skipped changes - -- None newly skipped in this run because there were no new upstream commits to classify. - -### Deferred selective frontend candidates - -- `39ca3ee8` Global terminal shortcuts from focused xterm: still deferred; looks safe for a future PR. -- `60387f67` Restore-defaults button limited to General settings: still deferred pending visual review against Kodo settings divergence. - -### Checks - -- `bun fmt` ✅ -- `bun lint` ✅ -- `bun typecheck` ✅ -- `cd apps/server && bun run test src/workspace/Layers/WorkspaceEntries.test.ts -t browse` ✅ -- `cd apps/server && bun run test src/server.test.ts -t filesystem.browse` ✅ -- `cd apps/web && bun run test src/wsNativeApi.test.ts src/lib/projectPaths.test.ts` ✅ - -## 2026-04-18 - -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@be7628c915e6db187efe26328bedc62fedba0c76` -- Upstream range reviewed: `pingdotgg/t3code@2d87574e62d616d890497d5b7d48201aa06d4dce..9df3c640210fecccb58f7fbc735f81ca0ee011bd` -- Upstream release window: `v0.0.20..main@2026-04-17` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 - -### Classification - -- `2d87574e` `chore(release): prepare v0.0.20` — `SKIP`: release version churn only. -- `505db9f6` `try out blacksmith for releases (#2101)` — `SKIP`: release pipeline divergence. -- `b991b9b9` `Revert to Github Runner for Windows (#2103)` — `SKIP`: release pipeline divergence. -- `ed6b7fbf` `fix(server): honor gitignored files in workspace search (#2078)` — `ADAPT`: ported the server-layer wiring and focused regression test without upstream harness churn. -- `8dba2d64` `Adopt Node-native TypeScript for desktop and server (#2098)` — `MANUAL`: broad tooling/runtime refactor across desktop, server, scripts, and contracts. -- `54179c86` `Update workflow to use ubuntu-24.04 runner (#2110)` — `SKIP`: release/CI runner policy is out of scope for this sync. -- `d8d32969` `Show thread status in command palette (#2107)` — `SKIP`: command-palette UI expansion conflicts with Kodo workflow boundaries. -- `a7a44d06` `Fix Windows PATH hydration and repair (#1729)` — `MANUAL`: valuable runtime hardening, but large shared shell/runtime rewrite on top of prior sync work. -- `f297e30e` `Clean up invalid pending approval projections (#2106)` — `APPLY` -- `df9d3400` `Modernize release workflow runners (#2129)` — `SKIP`: release workflow divergence. -- `40009735` `Extract backend startup readiness coordination (#2133)` — `MANUAL`: desktop startup refactor touches Kodo-specific startup/runtime behavior. -- `721b6b4c` `Preserve provider bindings when stopping sessions (#2125)` — `ADAPT`: ported the provider-binding persistence fix while keeping Kodo’s current tests and session scaffolding. -- `52a60678` `Throttle nightly release workflow to every 3 hours (#2134)` — `SKIP`: nightly release policy divergence. -- `39ca3ee8` `fix(web): bypass xterm for global terminal shortcuts (#1580)` — `SELECTIVE FRONTEND`: safe candidate, but deferred to keep this batch narrow after landing the higher-value terminal toggle fix. -- `ce94feee` `feat: add opencode provider support (#1758)` — `SKIP`: large new provider/product surface outside bounded sync scope. -- `60387f67` `fix: show restore defaults only on General settings (#1710)` — `SELECTIVE FRONTEND`: safe candidate, but deferred because it touches Kodo-owned settings surfacing and is lower priority than runtime work. -- `4e0c003e` `fix(web): allow deleting non-empty projects from the warning toast (#1264)` — `MANUAL`: mixed server/client project-deletion workflow change needs Kodo product judgment. -- `a3b1df52` `Add Claude Opus 4.5 to built-in Claude models (#2143)` — `SKIP`: visible provider/model surface change. -- `0f184c28` `fix(web): use capture-phase keydown listener so CTRL+J toggles terminal from terminal focus on Windows (#2113) (#2142)` — `SELECTIVE FRONTEND` -- `9c64f12e` `Add ACP support with Cursor provider (#1355)` — `SKIP`: major new provider/runtime architecture and package surface. -- `29cb917a` `Guard release workflow jobs from upstream failures (#2146)` — `SKIP`: release workflow divergence. -- `8ac57f79` `Guard release workflow jobs on upstream success (#2147)` — `SKIP`: release workflow divergence. -- `9df3c640` `Use GitHub App token for release uploads (#2149)` — `SKIP`: release workflow divergence. - -### Applied changes - -- `f297e30e` Added migration `025_CleanupInvalidProjectionPendingApprovals` to scrub invalid persisted pending-approval rows. -- Restored missing local migration file `023_ProjectionThreadShellSummary` from Kodo history so the sync branch’s migration registry is internally consistent. - -### Adapted changes - -- `ed6b7fbf` Wired `WorkspaceEntries` through `GitCore` in [`apps/server/src/server.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/server.ts) and added a regression test in [`apps/server/src/server.test.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/server.test.ts) so workspace search respects gitignored paths. -- `721b6b4c` Updated provider stop-session handling in [`apps/server/src/provider/Layers/ProviderService.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/provider/Layers/ProviderService.ts) and [`apps/server/src/orchestration/Layers/ProviderCommandReactor.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts) to preserve provider bindings after stop/restart cycles. - -### Selective frontend changes ported - -- `0f184c28` Updated [`apps/web/src/components/ChatView.tsx`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/web/src/components/ChatView.tsx) so the terminal toggle shortcut is captured while terminal focus is inside xterm on Windows, with regression coverage in [`apps/web/src/keybindings.test.ts`](/mnt/c/Users/Admin/.codex/worktrees/b83f/KodoCode/apps/web/src/keybindings.test.ts). - -### Manual-review candidates - -- `8dba2d64` Node-native TypeScript adoption: too broad for a bounded sync. -- `a7a44d06` Windows PATH hydration/repair: valuable, but large shared-runtime adaptation. -- `40009735` Backend startup readiness extraction: overlaps Kodo desktop startup policy. -- `4e0c003e` Non-empty project deletion flow: mixed server/client workflow needs product review. - -### Skipped changes - -- Release workflow and packaging changes: `2d87574e`, `505db9f6`, `b991b9b9`, `54179c86`, `df9d3400`, `52a60678`, `29cb917a`, `8ac57f79`, `9df3c640` -- Product-surface/provider expansions: `ce94feee`, `a3b1df52`, `9c64f12e` -- Command-palette UI expansion: `d8d32969` - -### Deferred selective frontend candidates - -- `39ca3ee8` Global terminal shortcuts from focused xterm: deferred after landing the narrower Ctrl+J fix first; looks safe for a future PR. -- `60387f67` Restore-defaults button limited to General settings: deferred because Kodo’s settings surface is already diverging; likely safe for a future PR with visual review. - -### Checks - -- `bun fmt` ✅ -- `bun lint` ✅ -- `bun typecheck` ⚠️ fails in pre-existing `apps/web` Base UI / `ButtonProps` typing errors unrelated to this sync batch. -- `cd apps/web && bun run test src/keybindings.test.ts` ✅ -- `cd apps/server && bun run test ...` ⚠️ blocked by pre-existing server test environment issues: missing `multipasta/node` resolution and missing migration `023` before the local restore commit. - -## 2026-04-17 - -- Fork branch: `sync/upstream-2026-04-17` -- Fork base: `boggedbrush/KodoCode@9eb9c1a9` -- Upstream range reviewed: `pingdotgg/t3code@e3004ae806d4e9a81e03ff919f50d2d34c37ffe7..b2cca674dfdf93430460fe08e1ce0d857e30bd83` -- Upstream release window: `v0.0.17..v0.0.20` -- Fork PR: https://github.com/boggedbrush/KodoCode/pull/7 - -### Classification +## Prior Sync History -- `a3dadf31` `chore(release): prepare v0.0.17` — `SKIP`: upstream release-prep version churn only. -- `678f827f` `Remove Claude subscription-based model adjustment (#1899)` — `APPLY` -- `e2316814` `Fix worktree base branch updates for active draft (#1900)` — `SKIP`: workflow/UI-coupled draft branch behavior. -- `12c3af78` `feat(desktop): add "Copy Image" to right-click context menu (#1052)` — `SKIP`: desktop UI feature. -- `5fa09fa2` `[codex] fix composer footer compact layout (#1894)` — `SKIP`: web UI layout. -- `4ae9de31` `Stabilize auth session cookies per server mode (#1898)` — `MANUAL`: valuable auth hardening, but conflicted with Kodo auth/desktop runtime changes across multiple files. -- `58e5f714` `Add provider skill discovery (#1905)` — `MANUAL`: backend value exists, but upstream implementation is tightly coupled to composer/menu UI surfaces. -- `e0e01b4a` `Handle deleted git directories as non-repositories (#1907)` — `APPLY` -- `b80e8476` `Memoize derived thread reads (#1908)` — `SKIP`: frontend state/render optimization. -- `97880e88` `fix(web): resolve logical-to-physical key mismatch in project drag reorder (#1904)` — `SKIP`: web UI interaction. -- `26cc1fff` `Add assistant message copy action and harden related test/storage fallbacks (#1211)` — `SKIP`: chat UI feature. -- `1f4a3f65` `Fix opening urls wrapped across lines in the terminal (#1913)` — `SKIP`: terminal/web presentation behavior. -- `5467d119` `fix(web): prevent number-key shortcuts from hijacking input in focused editor (#1810)` — `SKIP`: web editor UX. -- `934037cb` `feat(web): add extensible command palette (#1103)` — `SKIP`: command-palette UI dominates the mixed change. -- `f9372a4c` `chore(desktop): separate dev AppUserModelID on Windows (#1934)` — `SKIP`: desktop shell presentation/platform polish. -- `f9019cd6` `Coalesce status refreshes by remote (#1940)` — `MANUAL`: adapted to keep Kodo non-interactive git hardening while porting the refresh-coalescing fix. -- `2fce84a1` `fix: quote editor launch args on Windows to support paths with spaces (#1805)` — `APPLY` -- `f59ee36b` `fix(web): allow concurrent browser tests to retry ports (#1951)` — `SKIP`: browser-test harness only. -- `7a008461` `fix: Align token usage metrics for both Claude and Codex (#1943)` — `APPLY` -- `94d13a2b` `Preserve live stream subscriptions across explicit reconnects (#1972)` — `SKIP`: reconnect UX/runtime mix touches frontend behavior. -- `96c9306d` `Migrate chat scrolling and branch lists to LegendList (#1953)` — `SKIP`: frontend virtualization/list rendering. -- `dff8784a` `window controls overlay (windows&linux) (#1969)` — `SKIP`: desktop/web presentation. -- `850c9125` `fix(desktop): increase backend readiness timeout from 10s to 30s (#1979)` — `SKIP`: desktop startup policy change conflicts with Kodo release/runtime path. -- `57d7746a` `fix(web): replace turn strip overlay gradients with mask-image fade (#1949)` — `SKIP`: styling. -- `f7fa62aa` `Add shell snapshot queries for orchestration state (#1973)` — `MANUAL`: backend value exists, but not needed for this batch. -- `1bf048eb` `fix: avoid copy button overlapping long code blocks (#1985)` — `SKIP`: chat UI. -- `f2205bdc` `Pad composer model picker to prevent ring clipping (#1992)` — `SKIP`: styling/layout. -- `801b83e9` `Allow empty server threads to bootstrap new worktrees (#1936)` — `SKIP`: mixed commit heavily coupled to branch-toolbar and chat UI. -- `77fcad35` `Prevent live thread branches from regressing to temp worktree names (#1995)` — `SKIP`: thread/branch presentation coupling. -- `047a0a69` `fix: add pointer cursor to the permissions mode select trigger (#1997)` — `SKIP`: styling. -- `9b29be91` `docs: Document environment prep before local development (#1975)` — `SKIP`: docs only. -- `5f7becf3` `feat: Add Kiro editor support to open picker (#1974)` — `APPLY` -- `cadd7086` `feat: show full thread title in a tooltip when hovering sidebar thread names (#1994)` — `SKIP`: sidebar UI. -- `f5ecca44` `Clear tracked RPCs on reconnect (#2000)` — `SKIP`: frontend reconnect behavior. -- `6f699346` `Use latest user message time for thread timestamps (#1996)` — `SKIP`: thread list UX. -- `d18e43b6` `fix: lost provider session recovery (#1938)` — `APPLY` -- `33dadb5a` `Fix thread timeline autoscroll and simplify branch state (#2002)` — `SKIP`: thread timeline UX. -- `569fea87` `Warm sidebar thread detail subscriptions (#2001)` — `SKIP`: sidebar performance/UI behavior. -- `5f7ec73a` `Fix new-thread draft reuse for worktree defaults (#2003)` — `SKIP`: new-thread frontend flow. -- `9dcea68b` `Refresh git status after branch rename and worktree setup (#2005)` — `MANUAL`: runtime fix applied while preserving Kodo server-test scaffolding. -- `008ac5c3` `Cache provider status and gate desktop startup (#1962)` — `MANUAL`: mixed startup/runtime change deferred because it conflicts with Kodo desktop startup behavior. -- `2e42f3fd` `Improve shell PATH hydration and fallback detection (#1799)` — `APPLY` -- `c9b07d66` `Backfill projected shell summaries and stale approval cleanup (#2004)` — `MANUAL`: projection and migration changes merged into Kodo persistence state. -- `0d280262` `fix(claude): emit plan events for TodoWrite during input streaming (#1541)` — `SKIP`: upstream plan/composer UI coupling. -- `409ff90a` `Nightly release channel (#2012)` — `SKIP`: release channel/branding flow diverges in Kodo. -- `9ff31f8c` `Fix nightly desktop product name (#2025)` — `SKIP`: nightly branding. -- `44afe784` `Add filesystem browse API and command palette project picker (#2024)` — `MANUAL`: backend browse API may be useful later, but the commit is tied to upstream command-palette UI and project-creation flow. -- `7968f278` `Fix terminal Cmd+Backspace on macOS (#2027)` — `SKIP`: frontend terminal UX. -- `28cb9db2` `feat(web): add tooltip to composer file mention pill (#1944)` — `SKIP`: UI. -- `68061af0` `Improve markdown file link UX (#1956)` — `SKIP`: frontend markdown UX. -- `5e1dd56d` `feat: add Launch Args setting for Claude provider (#1971)` — `SKIP`: settings-surface/UI coupling. -- `f9580ff0` `Default nightly desktop builds to the nightly update channel (#2049)` — `SKIP`: nightly packaging policy differs in Kodo. -- `5e13f535` `fix: remove trailing newline from CLAUDE.md symlink (#2052)` — `SKIP`: low-value repo housekeeping outside sync priorities. -- `d22c6f52` `fix: prevent user-input activities from leaking into pending approvals projection (#2051)` — `APPLY` -- `3e07f5a6` `feat: add Claude Opus 4.7 to built-in models (#2072)` — `SKIP`: visible provider/model surface change. -- `19d47408` `fix(web): prevent composer controls overlap on narrow windows (make plan sidebar responsive) (#1198)` — `SKIP`: responsive UI. -- `7a08fcf2` `fix(server): drop stale text generation options when resetting text-gen model selection (#2076)` — `MANUAL`: skipped because upstream settings model is behind Kodo’s preset/settings evolution. -- `188a40c3` `feat: configurable project grouping (#2055)` — `SKIP`: project grouping is a user-visible workflow/settings surface. -- `e0117b27` `Fix Claude Process leak[MEMORY INTENSIVE], archiving, and stale claude session monitoring. (#2042)` — `MANUAL`: large runtime/session rewrite deferred due extensive conflicts. -- `d90e15d1` `fix(server): extend negative repository identity cache ttl (#2083)` — `APPLY` -- `6891c77d` `Build for Windows ARM (#2080)` — `SKIP`: release/build pipeline conflicts with Kodo packaging changes. -- `b7df3dfc` `[codex] Fix Windows release manifest publishing (#2095)` — `SKIP`: release pipeline divergence. -- `54904386` `fix: guard against missing sidebarProjectGroupingOverrides in client settings (#2099)` — `SKIP`: client settings/frontend behavior. -- `b2cca674` `ci(release): install deps before finalize version bump (#2100)` — `SKIP`: release workflow divergence. +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. From 9c2be103d5bd8cf96f2c104573c9cfa80a0fc12c Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:56:15 -0400 Subject: [PATCH 36/36] docs(sync): correct 2026-04-24 PR link --- docs/upstream-sync-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/upstream-sync-log.md b/docs/upstream-sync-log.md index bf8b79f7..88224fb6 100644 --- a/docs/upstream-sync-log.md +++ b/docs/upstream-sync-log.md @@ -6,7 +6,7 @@ - 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/8 +- Fork PR: https://github.com/boggedbrush/KodoCode/pull/13 ### Classification