diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f50aa8f882..c66691723c9 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -121,13 +121,14 @@ describe("DesktopClientSettings", () => { assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError); assert.equal(error.operation, "replace-settings-file"); assert.equal(error.path, environment.clientSettingsPath); - assert.instanceOf(error.cause, PlatformError.PlatformError); - assert.isString(error.cause.stack); + const platformCause = error.cause as PlatformError.PlatformError; + assert.equal(platformCause._tag, "PlatformError"); + assert.isString(platformCause.stack); assert.equal( error.message, `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`, ); - assert.notInclude(error.message, error.cause.message); + assert.notInclude(error.message, platformCause.message); }), ), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index a7767b50a15..466f7c3144b 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics anyUnknownInErrorContext:off - The CLI runner reports framework-owned parse/runtime failures at the process boundary. + import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -56,9 +58,9 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => export const cli = makeCli(); if (import.meta.main) { - Command.run(cli, { version: packageJson.version }).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), - NodeRuntime.runMain, - ); + const cliMain = Effect.scoped( + Command.run(cli, { version: packageJson.version }).pipe(Effect.provide(CliRuntimeLayer)), + ) as Effect.Effect; + + NodeRuntime.runMain(cliMain); } diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index 0eeee1b6f3f..c2f867b96e0 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -1,4 +1,4 @@ -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { parsePatchFiles } from "@pierre/diffs"; export interface TurnDiffFileSummary { readonly path: string; diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 256f10fb3b8..e2822f1b7c0 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -17,6 +17,7 @@ import type { GitActionProgressEvent, GitPreparePullRequestThreadInput, ModelSelection, + SourceControlProviderInfo, ThreadId, } from "@t3tools/contracts"; @@ -639,6 +640,8 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; + sourceControlProviderContext?: SourceControlProviderRegistry.SourceControlProviderHandle["context"]; + sourceControlProviderContextSource?: SourceControlProviderRegistry.SourceControlProviderHandle["contextSource"]; textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { @@ -661,7 +664,12 @@ function makeManager(input?: { Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), - resolveHandle: () => Effect.succeed({ provider, context: null }), + resolveHandle: () => + Effect.succeed({ + provider, + context: input?.sourceControlProviderContext ?? null, + contextSource: input?.sourceControlProviderContextSource ?? null, + }), resolve: () => Effect.succeed(provider), discover: Effect.succeed([]), }), @@ -696,6 +704,18 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(NodeServices.layer), ); +const githubProvider = { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", +} satisfies SourceControlProviderInfo; + +const gitlabProvider = { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", +} satisfies SourceControlProviderInfo; + it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status includes PR metadata when branch already has an open PR", () => Effect.gen(function* () { @@ -739,6 +759,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status prefers branch remote over detected provider context", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["remote", "add", "origin", "git@gitlab.com:pingdotgg/t3code.git"]); + yield* runGit(repoDir, ["remote", "add", "upstream", "git@github.com:pingdotgg/t3code.git"]); + yield* runGit(repoDir, ["checkout", "-b", "branch-remote"]); + yield* runGit(repoDir, ["config", "branch.branch-remote.remote", "upstream"]); + + const { manager } = yield* makeManager({ + sourceControlProviderContext: { + provider: gitlabProvider, + remoteName: "origin", + remoteUrl: "git@gitlab.com:pingdotgg/t3code.git", + }, + sourceControlProviderContextSource: "detected", + }); + + const status = yield* manager.localStatus({ cwd: repoDir }); + + expect(status.sourceControlProvider).toEqual(githubProvider); + }), + ); + it.effect("status trims PR metadata returned by gh before publishing it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 86e10d9f930..530ec90ad9b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -96,6 +96,7 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; +const STATUS_CACHE_KEY_SEPARATOR = "\u0000"; const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); const PR_LOOKUP_FAILURE_TTL = Duration.seconds(20); const PR_LOOKUP_CACHE_CAPACITY = 2_048; @@ -568,7 +569,8 @@ export const make = Effect.gen(function* () { const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const crypto = yield* Crypto.Crypto; - const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + const sourceControlProvider = (input: string | VcsStatusInput) => + sourceControlProviders.resolve(typeof input === "string" ? { cwd: input } : input); const serverSettingsService = yield* ServerSettings.ServerSettingsService; const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( @@ -612,10 +614,11 @@ export const make = Effect.gen(function* () { const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( function* ( - cwd: string, + target: VcsStatusInput, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) { + const { cwd } = target; const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0 && pullRequest.isCrossRepository !== true) { const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); @@ -637,7 +640,7 @@ export const make = Effect.gen(function* () { return; } - const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(target)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -668,14 +671,14 @@ export const make = Effect.gen(function* () { ); const configurePullRequestHeadUpstream = ( - cwd: string, + target: VcsStatusInput, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) => - configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( + configurePullRequestHeadUpstreamBase(target, pullRequest, localBranch).pipe( Effect.catch((error) => Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { - cwd, + cwd: target.cwd, localBranch, headBranch: pullRequest.headBranch, cause: error, @@ -685,10 +688,11 @@ export const make = Effect.gen(function* () { const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( function* ( - cwd: string, + target: VcsStatusInput, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) { + const { cwd } = target; const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { @@ -700,7 +704,7 @@ export const make = Effect.gen(function* () { return; } - const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(target)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -732,15 +736,15 @@ export const make = Effect.gen(function* () { ); const materializePullRequestHeadBranch = ( - cwd: string, + target: VcsStatusInput, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, localBranch = pullRequest.headBranch, ) => - materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( + materializePullRequestHeadBranchBase(target, pullRequest, localBranch).pipe( Effect.catch((primaryCause) => gitCore .fetchPullRequestBranch({ - cwd, + cwd: target.cwd, prNumber: pullRequest.number, branch: localBranch, }) @@ -748,7 +752,7 @@ export const make = Effect.gen(function* () { Effect.mapError( (fallbackCause) => new GitPullRequestMaterializationError({ - cwd, + cwd: target.cwd, pullRequestNumber: pullRequest.number, headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), headBranch: pullRequest.headBranch, @@ -769,7 +773,36 @@ export const make = Effect.gen(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); - const normalizeStatusCacheKey = canonicalizeExistingPath; + const normalizeStatusCwd = canonicalizeExistingPath; + const statusCacheKeyFromParts = (cwd: string, projectId?: VcsStatusInput["projectId"]) => + `${cwd}${STATUS_CACHE_KEY_SEPARATOR}${projectId ?? ""}`; + const statusInputFromCacheKey = (key: string): VcsStatusInput => { + const separatorIndex = key.lastIndexOf(STATUS_CACHE_KEY_SEPARATOR); + const cwd = separatorIndex === -1 ? key : key.slice(0, separatorIndex); + const projectId = separatorIndex === -1 ? "" : key.slice(separatorIndex + 1); + return projectId ? { cwd, projectId: projectId as VcsStatusInput["projectId"] } : { cwd }; + }; + const normalizeStatusCacheKey = (input: VcsStatusInput) => + normalizeStatusCwd(input.cwd).pipe( + Effect.map((cwd) => statusCacheKeyFromParts(cwd, input.projectId)), + ); + const invalidateStatusResultCache = (cache: Cache.Cache, cwd: string) => + normalizeStatusCwd(cwd).pipe( + Effect.flatMap((normalizedCwd) => + Cache.keys(cache).pipe( + Effect.flatMap((keys) => + Effect.forEach( + keys, + (key) => + key.startsWith(`${normalizedCwd}${STATUS_CACHE_KEY_SEPARATOR}`) + ? Cache.invalidate(cache, key) + : Effect.void, + { discard: true }, + ), + ), + ), + ), + ); const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, @@ -783,14 +816,16 @@ export const make = Effect.gen(function* () { behindCount: 0, aheadOfDefaultCount: 0, } satisfies GitVcsDriver.GitStatusDetails; - const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cacheKey: string) { + const input = statusInputFromCacheKey(cacheKey); + const cwd = input.cwd; const details = yield* gitCore .statusDetailsLocal(cwd) .pipe( Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), ); const hostingProvider = details.isRepo - ? yield* resolveHostingProvider(cwd, details.branch) + ? yield* resolveHostingProvider(input, details.branch) : null; return { @@ -808,37 +843,53 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateLocalStatusResultCache = (cwd: string) => - normalizeStatusCacheKey(cwd).pipe( - Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), - ); + invalidateStatusResultCache(localStatusResultCache, cwd); // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh // on their own, slower cadence: ahead/behind counts stay fresh on every // status poll while the PR association is re-fetched at most once per // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump - // the epoch (invalidateStatus) to bypass the cache immediately. + // the epoch (invalidateStatus) to bypass the cache immediately. Epochs are + // keyed by normalized cwd so a refresh invalidates every project-scoped + // entry for that repository at once. const prLookupEpochByCwd = new Map(); const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; const bumpPrLookupEpoch = (cwd: string) => - normalizeStatusCacheKey(cwd).pipe( - Effect.map((cacheKey) => { - prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); + normalizeStatusCwd(cwd).pipe( + Effect.map((normalizedCwd) => { + prLookupEpochByCwd.set(normalizedCwd, prLookupEpoch(normalizedCwd) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null upstreamRef. - const prLookupCacheKey = (cwd: string, details: { branch: string; upstreamRef: string | null }) => - [cwd, details.branch, details.upstreamRef ?? "", String(prLookupEpoch(cwd))].join("\u0000"); + // Cache keys are NUL-joined [cwd, projectId, branch, upstreamRef, epoch] — + // none of the segments can contain a NUL byte, and refs are never empty, so + // "" decodes back to a null upstreamRef (and "" to an absent projectId). + // The projectId segment keeps lookups distinct per project because a + // project-level remote override changes which provider answers the lookup. + const prLookupCacheKey = ( + input: VcsStatusInput, + details: { branch: string; upstreamRef: string | null }, + ) => + [ + input.cwd, + input.projectId ?? "", + details.branch, + details.upstreamRef ?? "", + String(prLookupEpoch(input.cwd)), + ].join(STATUS_CACHE_KEY_SEPARATOR); const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = ""] = key.split("\u0000"); + const [cwd = "", projectId = "", branch = "", upstreamRef = ""] = key.split( + STATUS_CACHE_KEY_SEPARATOR, + ); + const input: VcsStatusInput = projectId + ? { cwd, projectId: projectId as VcsStatusInput["projectId"] } + : { cwd }; const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, }; return resolveBranchHeadContext(cwd, details).pipe( Effect.flatMap((headContext) => - findLatestPrForHeadContext(cwd, headContext).pipe( + findLatestPrForHeadContext(input, headContext).pipe( Effect.map((latest) => ({ latest, headContext })), ), ), @@ -908,13 +959,16 @@ export const make = Effect.gen(function* () { return lastKnown.pr; }; const lookupStatusPr = Effect.fn("lookupStatusPr")(function* ( - cwd: string, + input: VcsStatusInput, details: { branch: string; upstreamRef: string | null; isDefaultBranch: boolean }, ) { - // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first - // `push -u`) must not orphan the fallback value for the same branch. - const branchKey = `${cwd}\u0000${details.branch}`; - return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + const cwd = input.cwd; + // Keyed by (cwd, projectId, branch) only: the upstream ref changing (e.g. + // a first `push -u`) must not orphan the fallback value for the same + // branch. The projectId keeps fallbacks apart when project remote + // overrides point the same repository at different providers. + const branchKey = [cwd, input.projectId ?? "", details.branch].join(STATUS_CACHE_KEY_SEPARATOR); + return yield* Cache.get(prLookupCache, prLookupCacheKey(input, details)).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; // On the default branch, only surface open PRs. @@ -960,9 +1014,11 @@ export const make = Effect.gen(function* () { ); }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( - cwd: string, + cacheKey: string, options?: GitVcsDriver.GitRemoteStatusOptions, ) { + const input = statusInputFromCacheKey(cacheKey); + const cwd = input.cwd; const details = yield* gitCore .statusDetailsRemote(cwd, options) .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); @@ -972,7 +1028,7 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* lookupStatusPr(cwd, { + ? yield* lookupStatusPr(input, { branch: details.branch, upstreamRef: details.upstreamRef, isDefaultBranch: details.isDefaultBranch, @@ -992,17 +1048,29 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateRemoteStatusResultCache = (cwd: string) => - normalizeStatusCacheKey(cwd).pipe( - Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)), - ); + invalidateStatusResultCache(remoteStatusResultCache, cwd); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( - cwd: string, + input: VcsStatusInput, branch: string | null, ) { + const cwd = input.cwd; + const providerHandle = yield* sourceControlProviders.resolveHandle(input).pipe( + Effect.catch(() => + Effect.succeed({ + provider: null, + context: null, + contextSource: null, + }), + ), + ); + if (providerHandle.contextSource === "override" && providerHandle.context) { + return providerHandle.context.provider; + } + const preferredRemoteName = branch === null ? "origin" @@ -1011,7 +1079,14 @@ export const make = Effect.gen(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + const providerFromBranchRemote = remoteUrl + ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) + : null; + if (providerFromBranchRemote) { + return providerFromBranchRemote; + } + + return providerHandle.context?.provider ?? null; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( @@ -1110,7 +1185,7 @@ export const make = Effect.gen(function* () { }); const findOpenPr = Effect.fn("findOpenPr")(function* ( - cwd: string, + target: VcsStatusInput, headContext: Pick< BranchHeadContext, | "headBranch" @@ -1120,8 +1195,9 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { + const { cwd } = target; for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const pullRequests = yield* (yield* sourceControlProvider(target)).listChangeRequests({ cwd, headSelector, state: "open", @@ -1149,13 +1225,14 @@ export const make = Effect.gen(function* () { }); const findLatestPrForHeadContext = Effect.fn("findLatestPrForHeadContext")(function* ( - cwd: string, + input: VcsStatusInput, headContext: BranchHeadContext, ) { + const cwd = input.cwd; const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const pullRequests = yield* (yield* sourceControlProvider(input)).listChangeRequests({ cwd, headSelector, state: "all", @@ -1179,10 +1256,11 @@ export const make = Effect.gen(function* () { return parsed[0] ?? null; }); const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( - cwd: string, + target: VcsStatusInput, result: Pick, ) { - const terms = yield* sourceControlProvider(cwd).pipe( + const { cwd } = target; + const terms = yield* sourceControlProvider(target).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), ); @@ -1227,7 +1305,7 @@ export const make = Effect.gen(function* () { branch: finalBranchContext.branch, upstreamRef: finalBranchContext.upstreamRef, }).pipe( - Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), + Effect.flatMap((headContext) => findOpenPr(target, headContext)), Effect.orElseSucceed(() => null), ); } @@ -1273,11 +1351,12 @@ export const make = Effect.gen(function* () { }); const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( - cwd: string, + target: VcsStatusInput, branch: string, upstreamRef: string | null, headContext: Pick, ) { + const { cwd } = target; const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; @@ -1290,7 +1369,7 @@ export const make = Effect.gen(function* () { } } - const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( + const defaultFromProvider = yield* sourceControlProvider(target).pipe( Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), Effect.orElseSucceed(() => null), ); @@ -1484,11 +1563,12 @@ export const make = Effect.gen(function* () { const runPrStep = Effect.fn("runPrStep")(function* ( modelSelection: ModelSelection, - cwd: string, + target: VcsStatusInput, fallbackBranch: string | null, emit: GitActionProgressEmitter, ) { - const provider = yield* sourceControlProvider(cwd); + const { cwd } = target; + const provider = yield* sourceControlProvider(target); const terms = getChangeRequestTerminologyForKind(provider.kind); const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -1512,7 +1592,7 @@ export const make = Effect.gen(function* () { upstreamRef: details.upstreamRef, }); - const existing = yield* findOpenPr(cwd, headContext); + const existing = yield* findOpenPr(target, headContext); if (existing) { return { status: "opened_existing" as const, @@ -1524,7 +1604,7 @@ export const make = Effect.gen(function* () { }; } - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + const baseBranch = yield* resolveBaseBranch(target, branch, details.upstreamRef, headContext); yield* emit({ kind: "phase_started", phase: "pr", @@ -1573,7 +1653,7 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - const created = yield* findOpenPr(cwd, headContext); + const created = yield* findOpenPr(target, headContext); if (!created) { return { status: "created" as const, @@ -1595,13 +1675,13 @@ export const make = Effect.gen(function* () { const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( function* (input) { - const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + const cacheKey = yield* normalizeStatusCacheKey(input); return yield* Cache.get(localStatusResultCache, cacheKey); }, ); const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { - const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + const cacheKey = yield* normalizeStatusCacheKey(input); if (options?.refreshUpstream === false) { return yield* readRemoteStatus(cacheKey, options); } @@ -1638,7 +1718,7 @@ export const make = Effect.gen(function* () { const resolvePullRequest: GitManager["Service"]["resolvePullRequest"] = Effect.fn( "resolvePullRequest", )(function* (input) { - const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + const pullRequest = yield* (yield* sourceControlProvider(input)) .getChangeRequest({ cwd: input.cwd, reference: normalizePullRequestReference(input.reference), @@ -1674,21 +1754,21 @@ export const make = Effect.gen(function* () { return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ + const pullRequestSummary = yield* (yield* sourceControlProvider(input)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { - yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ + yield* (yield* sourceControlProvider(input)).checkoutChangeRequest({ cwd: input.cwd, reference: normalizedReference, force: true, }); const details = yield* gitCore.statusDetails(input.cwd); yield* configurePullRequestHeadUpstream( - input.cwd, + input, { ...pullRequest, ...toPullRequestHeadRemoteInfo(pullRequestSummary), @@ -1707,7 +1787,7 @@ export const make = Effect.gen(function* () { ) { const details = yield* gitCore.statusDetails(worktreePath); yield* configurePullRequestHeadUpstream( - worktreePath, + { cwd: worktreePath, projectId: input.projectId }, { ...pullRequest, ...toPullRequestHeadRemoteInfo(pullRequestSummary), @@ -1774,7 +1854,7 @@ export const make = Effect.gen(function* () { } yield* materializePullRequestHeadBranch( - input.cwd, + input, pullRequestWithRemoteInfo, localPullRequestBranch, ); @@ -1958,7 +2038,7 @@ export const make = Effect.gen(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; const changeRequestTerms = wantsPr - ? yield* sourceControlProvider(input.cwd).pipe( + ? yield* sourceControlProvider(input).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), ) @@ -2005,12 +2085,12 @@ export const make = Effect.gen(function* () { .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), Effect.flatMap(() => - runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), + runPrStep(modelSelection, input, currentBranch, progress.emit), ), ) : { status: "skipped_not_requested" as const }; - const toast = yield* buildCompletionToast(input.cwd, { + const toast = yield* buildCompletionToast(input, { action: input.action, branch: branchStep, commit, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..8b735381754 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -20,6 +20,7 @@ import { ProjectId, ThreadId, TurnId, + type VcsStatusInput, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; @@ -260,7 +261,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const refreshStatus = vi.fn((_: string) => + const refreshStatus = vi.fn((_: string | VcsStatusInput) => Effect.succeed({ isRepo: true, hasPrimaryRemote: true, diff --git a/apps/server/src/platformError.ts b/apps/server/src/platformError.ts new file mode 100644 index 00000000000..59ee8a11d2b --- /dev/null +++ b/apps/server/src/platformError.ts @@ -0,0 +1,26 @@ +interface PlatformErrorReasonLike { + readonly _tag: string; + readonly module?: string; + readonly method?: string; + readonly syscall?: string; + readonly pathOrDescriptor?: unknown; + readonly cause?: unknown; +} + +export interface PlatformErrorLike { + readonly _tag: "PlatformError"; + readonly reason: PlatformErrorReasonLike; + readonly message?: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const isPlatformErrorLike = (error: unknown): error is PlatformErrorLike => + isRecord(error) && + error._tag === "PlatformError" && + isRecord(error.reason) && + typeof error.reason._tag === "string"; + +export const isPlatformNotFoundErrorLike = (error: unknown): error is PlatformErrorLike => + isPlatformErrorLike(error) && error.reason._tag === "NotFound"; diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079..d329a2dcc7b 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import { type OrchestrationProject, ProjectId, ServerSettingsError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -63,9 +64,11 @@ const makeTerminalManagerLayer = ( const testLayer = ( project: OrchestrationProject, terminal: Pick, + settings: Parameters[0] = {}, ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), + Layer.provideMerge(ServerSettings.layerTest(settings)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), ); @@ -139,6 +142,7 @@ describe("ProjectSetupScriptRunner", () => { cwd: "/repo/worktrees/a", worktreePath: "/repo/worktrees/a", env: { + API_BASE_URL: "https://api.example.test", T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a", }, @@ -148,7 +152,94 @@ describe("ProjectSetupScriptRunner", () => { terminalId: "setup-setup", data: "bun install\r", }); - }).pipe(Effect.provide(testLayer(project, { open, write }))); + }).pipe( + Effect.provide( + testLayer( + project, + { open, write }, + { + projectSettings: { + [project.id]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: { + API_BASE_URL: "https://api.example.test", + }, + disabledProviderInstanceIds: [], + }, + }, + }, + ), + ), + ); + }, + ); + + it.effect( + "runs the setup script without optional variables when settings are unavailable", + () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + const unavailableSettingsLayer = Layer.mock(ServerSettings.ServerSettingsService)({ + getProjectSettings: () => + Effect.fail( + new ServerSettingsError({ + settingsPath: "/tmp/settings.json", + operation: "read-file", + cause: new Error("settings unavailable"), + }), + ), + }); + const layer = ProjectSetupScriptRunner.layer.pipe( + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), + Layer.provideMerge(unavailableSettingsLayer), + Layer.provideMerge(makeTerminalManagerLayer({ open, write })), + ); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + + expect(result.status).toBe("started"); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenCalled(); + }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf48..81d248bf64f 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -7,6 +7,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export interface ProjectSetupScriptRunnerResultNoScript { @@ -40,7 +41,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass settings.actionEnvironment), + Effect.tapError((cause) => + Effect.logWarning("project setup action environment unavailable", { + projectId: project.id, + cause, + }), + ), + Effect.orElseSucceed(() => ({})), + ); const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, + extraEnv: actionEnvironment, }); yield* terminalManager diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 03c717abb14..b4b37978ec0 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; +import { isPlatformErrorLike } from "../../platformError.ts"; import { CodexShadowHomeEntryConflictError, CodexShadowHomePathConflictError, @@ -293,7 +293,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { effectiveHomePath: shadowHome, }); expect(error.path.startsWith(sharedHome)).toBe(true); - expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect(isPlatformErrorLike(error.cause)).toBe(true); expect(error.message).toBe( `Codex shadow home filesystem operation 'makeDirectory' failed for '${error.path}'.`, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f00d3cc662..a8c677e0feb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -22,6 +22,7 @@ import { type ServerProvider, type ServerProviderSlashCommand, type ServerSettings as ContractServerSettings, + type ServerSettingsPatch, } from "@t3tools/contracts"; import * as PlatformError from "effect/PlatformError"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -281,19 +282,59 @@ function makeMutableServerSettingsService( const settingsRef = yield* Ref.make(initial); const changes = yield* PubSub.unbounded(); + const commitSettings = (makePatch: (current: ContractServerSettings) => ServerSettingsPatch) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = decodeServerSettings( + encodeServerSettings(applyServerSettingsPatch(current, makePatch(current))), + ); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }); + return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(settingsRef), - updateSettings: (patch) => - Effect.gen(function* () { - const current = yield* Ref.get(settingsRef); - const next = applyServerSettingsPatch(current, patch); - encodeServerSettings(next); - yield* Ref.set(settingsRef, next); - yield* PubSub.publish(changes, next); - return next; - }), + getProjectSettings: (projectId) => + Ref.get(settingsRef).pipe( + Effect.map( + (settings) => + settings.projectSettings[projectId] ?? { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }, + ), + ), + updateSettings: (patch) => commitSettings(() => patch), + updateProjectSettings: (projectId, patch) => + commitSettings((settings) => ({ + projectSettings: { + ...settings.projectSettings, + [projectId]: { + ...(settings.projectSettings[projectId] ?? { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }), + ...patch, + }, + }, + })).pipe( + Effect.map( + (settings) => + settings.projectSettings[projectId] ?? { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }, + ), + ), get streamChanges() { return Stream.fromPubSub(changes); }, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d..a41a09b6397 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -9,11 +9,11 @@ import type { ServerProviderState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { isPlatformNotFoundErrorLike } from "../platformError.ts"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -69,7 +69,7 @@ export function nonEmptyTrimmed(value: string | undefined): string | undefined { export function isCommandMissingCause(error: unknown): boolean { if (isProviderCommandNotFoundError(error)) return true; - return error instanceof PlatformError.PlatformError && error.reason._tag === "NotFound"; + return isPlatformNotFoundErrorLike(error); } export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Command) => diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts index 839eb73b2bb..ff2d6f6110e 100644 --- a/apps/server/src/review/ReviewService.test.ts +++ b/apps/server/src/review/ReviewService.test.ts @@ -3,9 +3,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../config.ts"; +import { isPlatformErrorLike } from "../platformError.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as ReviewService from "./ReviewService.ts"; @@ -93,7 +93,7 @@ describe("ReviewService", () => { assert.strictEqual(error.operation, "ReviewService.assertWorkspaceBoundCwd.canonicalizePath"); assert.strictEqual(error.cwd, invalidCwd); assert.match(error.detail, /Failed to resolve a path/); - assert.instanceOf(error.cause, PlatformError.PlatformError); + assert.isTrue(isPlatformErrorLike(error.cause)); assert.deepStrictEqual(detectCalls, []); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..42c22c39f61 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -23,10 +23,12 @@ import { ORCHESTRATION_WS_METHODS, type PreviewEvent, ProjectId, + type ProjectSettingsPatch, ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + type VcsStatusInput, WS_METHODS, WsRpcGroup, EditorId, @@ -562,7 +564,15 @@ const buildAppUnderTest = (options?: { start: Effect.void, ready: Effect.void, getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + getProjectSettings: () => Effect.succeed(ServerSettings.emptyProjectSettings), updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateProjectSettings: () => + Effect.succeed({ + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }), streamChanges: Stream.empty, ...options?.layers?.serverSettings, }), @@ -834,12 +844,24 @@ const parseSessionCookieFromWsUrl = ( }; }; +type NodeWebSocketConstructor = new ( + socketUrl: string | URL, + protocols?: string | ReadonlyArray, + options?: { readonly headers?: Record }, +) => globalThis.WebSocket; + +const nodeWebSocket = ( + NodeSocket.NodeWS as unknown as { + readonly WebSocket: NodeWebSocketConstructor; + } +).WebSocket; + const wsRpcProtocolLayer = (wsUrl: string) => { const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); const webSocketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, (socketUrl, protocols) => - new NodeSocket.NodeWS.WebSocket( + new nodeWebSocket( socketUrl, protocols, cookie ? { headers: { cookie } } : undefined, @@ -1976,7 +1998,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); const installEvents = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.cloudInstallRelayClient]({}).pipe(Stream.runCollect), + client[WS_METHODS.cloudInstallRelayClient]({}).pipe(Stream.take(3), Stream.runCollect), ), ); @@ -4750,6 +4772,605 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "routes websocket rpc projects.getDetails with detected and effective remote data", + () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getProjectShellById: (projectId) => + Effect.succeed( + projectId === defaultProjectId + ? Option.some({ + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + repositoryIdentity: null, + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }) + : Option.none(), + ), + }, + gitVcsDriver: { + execute: (input) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: + input.operation === "projects.getDetails.gitRoot" + ? "/tmp/default-project\n" + : input.operation === "projects.getDetails.branch" + ? "main\n" + : "origin\tgit@github.com:acme/widgets.git (fetch)\norigin\tgit@github.com:acme/widgets.git (push)\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const details = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsGetDetails]({ projectId: defaultProjectId }), + ), + ); + + assert.equal(details.id, defaultProjectId); + assert.equal(details.title, "Default Project"); + assert.equal(details.workspaceRoot, "/tmp/default-project"); + assert.deepEqual(details.settings, { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }); + assert.equal(details.detected.gitRoot, "/tmp/default-project"); + assert.equal(details.detected.branch, "main"); + assert.equal(details.detected.remotes.length, 1); + assert.equal(details.detected.remotes[0]?.name, "origin"); + assert.equal(details.detected.remotes[0]?.url, "git@github.com:acme/widgets.git"); + assert.equal(details.detected.remotes[0]?.provider?.kind, "github"); + assert.equal(details.detected.primaryRemote?.name, "origin"); + assert.equal(details.effective.title, "Default Project"); + assert.equal(details.effective.remote?.source, "detected"); + assert.equal(details.effective.remote?.provider, "github"); + assert.equal(details.effective.remote?.remoteName, "origin"); + assert.equal(details.effective.remote?.remoteUrl, "git@github.com:acme/widgets.git"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.getDetails preferring the remote override", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + repositoryIdentity: null, + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }), + ), + }, + serverSettings: { + getProjectSettings: () => + Effect.succeed({ + remoteOverride: { + provider: "gitlab" as const, + remoteUrl: "https://gitlab.com/acme/widgets.git", + webUrl: "https://gitlab.com/acme/widgets", + }, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }), + }, + gitVcsDriver: { + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const details = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsGetDetails]({ projectId: defaultProjectId }), + ), + ); + + assert.deepEqual(details.settings.remoteOverride, { + provider: "gitlab", + remoteUrl: "https://gitlab.com/acme/widgets.git", + webUrl: "https://gitlab.com/acme/widgets", + }); + assert.equal(details.detected.gitRoot, null); + assert.equal(details.detected.branch, null); + assert.deepEqual(details.detected.remotes, []); + assert.equal(details.detected.primaryRemote, null); + assert.equal(details.effective.remote?.source, "override"); + assert.equal(details.effective.remote?.provider, "gitlab"); + assert.equal(details.effective.remote?.remoteName, "origin"); + assert.equal(details.effective.remote?.remoteUrl, "https://gitlab.com/acme/widgets.git"); + assert.equal(details.effective.remote?.webUrl, "https://gitlab.com/acme/widgets"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("falls back to the detected remote when a saved override is unusable", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + repositoryIdentity: null, + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }), + ), + }, + serverSettings: { + getProjectSettings: () => + Effect.succeed({ + remoteOverride: { + provider: "github", + remoteUrl: "not-a-remote-url", + }, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }), + }, + gitVcsDriver: { + execute: (input) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: + input.operation === "projects.getDetails.gitRoot" + ? "/tmp/default-project\n" + : input.operation === "projects.getDetails.branch" + ? "main\n" + : "origin\tgit@github.com:acme/widgets.git (fetch)\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const details = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsGetDetails]({ projectId: defaultProjectId }), + ), + ); + + assert.equal(details.settings.remoteOverride?.remoteUrl, "not-a-remote-url"); + assert.equal(details.effective.remote?.source, "detected"); + assert.equal(details.effective.remote?.provider, "github"); + assert.equal(details.effective.remote?.remoteUrl, "git@github.com:acme/widgets.git"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.getDetails errors for an unknown project", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsGetDetails]({ + projectId: ProjectId.make("project-missing"), + }), + ).pipe(Effect.result), + ); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectDetailsError") { + assert.fail("Expected a ProjectDetailsError"); + } + assert.equal(result.failure.message, "Project was not found."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.updateSettings and persists the patch", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const updateCalls: Array<{ + readonly projectId: ProjectId; + readonly patch: ProjectSettingsPatch; + }> = []; + const persistedSettings = { + remoteOverride: { + provider: "github" as const, + remoteUrl: "https://github.com/acme/widgets.git", + }, + automaticGitFetchInterval: null, + actionEnvironment: { DEPLOY_ENV: "staging" }, + disabledProviderInstanceIds: [ProviderInstanceId.make("codex")], + }; + const patch = { + remoteOverride: { + provider: "github" as const, + remoteUrl: "https://github.com/acme/widgets.git", + }, + actionEnvironment: { DEPLOY_ENV: "staging" }, + disabledProviderInstanceIds: [ProviderInstanceId.make("codex")], + }; + const providers = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + repositoryIdentity: null, + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }), + ), + }, + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + updateProjectSettings: (projectId, settingsPatch) => + Effect.sync(() => { + updateCalls.push({ projectId, patch: settingsPatch }); + return persistedSettings; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsUpdateSettings]({ + projectId: defaultProjectId, + patch, + }), + ), + ); + + assert.deepEqual(response, persistedSettings); + assert.equal(updateCalls.length, 1); + assert.equal(updateCalls[0]?.projectId, defaultProjectId); + assert.deepEqual(updateCalls[0]?.patch, patch); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc projects.updateSettings rejects disabling every provider", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + let updateProjectSettingsCalled = false; + + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getProjectShellById: () => + Effect.succeed( + Option.some({ + id: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + repositoryIdentity: null, + defaultModelSelection, + scripts: [], + createdAt: now, + updatedAt: now, + }), + ), + }, + providerRegistry: { + getProviders: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: now, + models: [], + slashCommands: [], + skills: [], + }, + ]), + }, + serverSettings: { + updateProjectSettings: (_projectId, _patch) => + Effect.sync(() => { + updateProjectSettingsCalled = true; + return { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }; + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsUpdateSettings]({ + projectId: defaultProjectId, + patch: { + disabledProviderInstanceIds: [ProviderInstanceId.make("codex")], + }, + }), + ).pipe(Effect.result), + ); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectDetailsError") { + assert.fail("Expected a ProjectDetailsError"); + } + assert.equal( + result.failure.message, + "At least one provider must stay enabled for this project.", + ); + assert.equal(updateProjectSettingsCalled, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects thread.turn.start when the existing thread cannot be loaded", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.fail( + new PersistenceSqlError({ + operation: "getThreadShellById", + detail: "projection unavailable", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-thread-lookup-failed"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-thread-lookup-failed"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ).pipe(Effect.result), + ); + + if ( + result._tag !== "Failure" || + result.failure._tag !== "OrchestrationDispatchCommandError" + ) { + assert.fail("Expected an OrchestrationDispatchCommandError"); + } + assert.equal( + result.failure.message, + "SQL error in getThreadShellById: projection unavailable", + ); + assert.deepEqual(dispatchedCommands, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "rejects thread.turn.start when the provider instance is disabled for the project", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed(Option.some(makeDefaultOrchestrationThreadShell())), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + projectSettings: { + [defaultProjectId]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [defaultModelSelection.instanceId], + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-disabled-provider"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-disabled-provider"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ).pipe(Effect.result), + ); + + if ( + result._tag !== "Failure" || + result.failure._tag !== "OrchestrationDispatchCommandError" + ) { + assert.fail("Expected an OrchestrationDispatchCommandError"); + } + assert.equal( + result.failure.message, + `Provider instance "${defaultModelSelection.instanceId}" is disabled for this project.`, + ); + assert.deepEqual(dispatchedCommands, []); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("dispatches thread.turn.start when the provider instance stays enabled", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + }, + projectionSnapshotQuery: { + getThreadShellById: () => + Effect.succeed(Option.some(makeDefaultOrchestrationThreadShell())), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + projectSettings: { + [defaultProjectId]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [ProviderInstanceId.make("claudeAgent")], + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-enabled-provider"), + threadId: defaultThreadId, + message: { + messageId: MessageId.make("msg-enabled-provider"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ), + ); + + assert.equal(response.sequence, 1); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.turn.start"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc shell.openInEditor", () => Effect.gen(function* () { let openedInput: { cwd: string; editor: EditorId } | null = null; @@ -6745,7 +7366,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const dispatchedCommands: Array = []; const bootstrapGitOperations: string[] = []; - const refreshStatus = vi.fn((_: string) => + const refreshStatus = vi.fn((_: string | VcsStatusInput) => Effect.succeed({ isRepo: true, hasPrimaryRemote: true, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..0f0dba57198 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { createDefaultModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -17,10 +18,10 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); + assert.deepStrictEqual( + ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + createDefaultModelSelection(), + ); }); it.effect("enqueueCommand waits for readiness and then drains queued work", () => diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..79afa41ee3f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,12 +1,11 @@ import { CommandId, - DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, type ModelSelection, ProjectId, - ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { createDefaultModelSelection } from "@t3tools/shared/model"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -161,10 +160,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); -export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, -}); +export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => + createDefaultModelSelection(); export const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 487ae9b45b8..9bff15a1e38 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, ServerSettings, @@ -203,6 +204,30 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("updates project settings from the latest persisted snapshot", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const firstProjectId = ProjectId.make("project-1"); + const secondProjectId = ProjectId.make("project-2"); + + yield* Effect.all( + [ + serverSettings.updateProjectSettings(firstProjectId, { + actionEnvironment: { FIRST: "1" }, + }), + serverSettings.updateProjectSettings(secondProjectId, { + actionEnvironment: { SECOND: "2" }, + }), + ], + { concurrency: "unbounded" }, + ); + + const next = yield* serverSettings.getSettings; + assert.deepEqual(next.projectSettings[firstProjectId]?.actionEnvironment, { FIRST: "1" }); + assert.deepEqual(next.projectSettings[secondProjectId]?.actionEnvironment, { SECOND: "2" }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("preserves model when switching providers via textGenerationModelSelection", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640f..c9bfd2634ba 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -17,6 +17,9 @@ import { DEFAULT_SERVER_SETTINGS, isProviderDriverKind, type ModelSelection, + type ProjectId, + type ProjectSettings, + type ProjectSettingsPatch, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, ProviderDriverKind, @@ -56,6 +59,12 @@ const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); +export const emptyProjectSettings: ProjectSettings = { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], +}; const normalizeServerSettings = ( settings: ServerSettings, @@ -120,11 +129,22 @@ export class ServerSettingsService extends Context.Service< /** Read the current settings. */ readonly getSettings: Effect.Effect; + /** Read one project's persisted settings without materializing provider secrets. */ + readonly getProjectSettings: ( + projectId: ProjectId, + ) => Effect.Effect; + /** Patch settings and persist. Returns the new full settings object. */ readonly updateSettings: ( patch: ServerSettingsPatch, ) => Effect.Effect; + /** Update one project's settings from the latest persisted snapshot. */ + readonly updateProjectSettings: ( + projectId: ProjectId, + patch: ProjectSettingsPatch, + ) => Effect.Effect; + /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; } @@ -144,16 +164,39 @@ const makeTest = (overrides: DeepPartial = {}) => : {}), }); const currentSettingsRef = yield* Ref.make(initialSettings); + const writeSemaphore = yield* Semaphore.make(1); + + const commitSettings = (makePatch: (current: ServerSettings) => ServerSettingsPatch) => + writeSemaphore.withPermits(1)( + Ref.get(currentSettingsRef).pipe( + Effect.map((currentSettings) => + applyServerSettingsPatch(currentSettings, makePatch(currentSettings)), + ), + Effect.flatMap(normalizeServerSettings), + Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + ), + ); return { start: Effect.void, ready: Effect.void, getSettings: Ref.get(currentSettingsRef), - updateSettings: (patch) => + getProjectSettings: (projectId) => Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), - Effect.flatMap(normalizeServerSettings), - Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + Effect.map((settings) => settings.projectSettings[projectId] ?? emptyProjectSettings), + ), + updateSettings: (patch) => commitSettings(() => patch), + updateProjectSettings: (projectId, patch) => + commitSettings((settings) => ({ + projectSettings: { + ...settings.projectSettings, + [projectId]: { + ...(settings.projectSettings[projectId] ?? emptyProjectSettings), + ...patch, + }, + }, + })).pipe( + Effect.map((settings) => settings.projectSettings[projectId] ?? emptyProjectSettings), ), streamChanges: Stream.empty, } satisfies ServerSettingsService["Service"]; @@ -556,6 +599,23 @@ const make = Effect.gen(function* () { yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); }); + const commitSettings = (makePatch: (current: ServerSettings) => ServerSettingsPatch) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const nextPersisted = yield* persistProviderEnvironmentSecrets( + current, + applyServerSettingsPatch(current, makePatch(current)), + ); + const next = yield* normalizeServerSettings(nextPersisted); + yield* writeSettingsAtomically(next); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + const materialized = yield* materializeProviderEnvironmentSecrets(next); + return resolveTextGenerationProvider(materialized); + }), + ); + return { start, ready: Deferred.await(startedDeferred), @@ -563,21 +623,22 @@ const make = Effect.gen(function* () { Effect.flatMap(materializeProviderEnvironmentSecrets), Effect.map(resolveTextGenerationProvider), ), - updateSettings: (patch) => - writeSemaphore.withPermits(1)( - Effect.gen(function* () { - const current = yield* getSettingsFromCache; - const nextPersisted = yield* persistProviderEnvironmentSecrets( - current, - applyServerSettingsPatch(current, patch), - ); - const next = yield* normalizeServerSettings(nextPersisted); - yield* writeSettingsAtomically(next); - yield* Cache.set(settingsCache, cacheKey, next); - yield* emitChange(next); - const materialized = yield* materializeProviderEnvironmentSecrets(next); - return resolveTextGenerationProvider(materialized); - }), + getProjectSettings: (projectId) => + getSettingsFromCache.pipe( + Effect.map((settings) => settings.projectSettings[projectId] ?? emptyProjectSettings), + ), + updateSettings: (patch) => commitSettings(() => patch), + updateProjectSettings: (projectId, patch) => + commitSettings((settings) => ({ + projectSettings: { + ...settings.projectSettings, + [projectId]: { + ...(settings.projectSettings[projectId] ?? emptyProjectSettings), + ...patch, + }, + }, + })).pipe( + Effect.map((settings) => settings.projectSettings[projectId] ?? emptyProjectSettings), ), get streamChanges() { return Stream.fromPubSub(changesPubSub).pipe( diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 609efe4df4c..335d4eceeb8 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -1,7 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { @@ -12,6 +11,7 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { isPlatformNotFoundErrorLike } from "../platformError.ts"; import { decodeAzureDevOpsPullRequestJson, decodeAzureDevOpsPullRequestListJson, @@ -90,13 +90,13 @@ export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass 0 ? hostWithPath.slice(0, separatorIndex).toLowerCase() : null; + } + + try { + const hostname = new URL(trimmed).hostname.toLowerCase(); + return hostname || null; + } catch { + return null; + } +} + +export function parseBaseUrl(value: string): string | null { + try { + const url = new URL(value); + return `${url.protocol}//${url.host}`; + } catch { + const host = parseRemoteHost(value); + return host ? `https://${host}` : null; + } +} + +export function providerName(kind: SourceControlProviderKind, baseUrl: string | null): string { + switch (kind) { + case "github": + return baseUrl === "https://github.com" ? "GitHub" : "GitHub Self-Hosted"; + case "gitlab": + return baseUrl === "https://gitlab.com" ? "GitLab" : "GitLab Self-Hosted"; + case "azure-devops": + return "Azure DevOps"; + case "bitbucket": + return baseUrl === "https://bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted"; + case "unknown": + return parseRemoteHost(baseUrl ?? "") ?? "Source control"; + } +} + +export function providerInfoFromOverride( + override: ProjectRemoteOverride, +): SourceControlProviderInfo | null { + const baseUrl = parseBaseUrl(override.webUrl ?? "") ?? parseBaseUrl(override.remoteUrl); + if (!baseUrl) { + return null; + } + return { + kind: override.provider, + name: providerName(override.provider, baseUrl), + baseUrl, + }; +} + +export function providerContextFromOverride( + override: ProjectRemoteOverride, +): SourceControlProvider.SourceControlProviderContext | null { + const provider = providerInfoFromOverride(override); + return provider + ? { + provider, + remoteName: override.remoteName ?? "origin", + remoteUrl: override.remoteUrl, + } + : null; +} diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04c..35334a4c5ba 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -7,6 +7,8 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -30,6 +32,10 @@ const sourceControlProviderRegistryTestLayer = (input: { Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + ServerSettings.layerTest(), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f9..d438da4d493 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -4,10 +4,18 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsRepositoryDetectionError } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + ServerSettingsError, + VcsRepositoryDetectionError, +} from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import type * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -15,6 +23,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; +import { parseRemoteHost } from "./RemoteOverride.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -40,8 +49,21 @@ function makeRegistry(input: { }>; readonly process?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; + readonly settings?: Parameters[0]; + readonly serverSettings?: Partial; }) { const driver = { + detectRepository: () => + Effect.succeed({ + kind: "git" as const, + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), listRemotes: () => Effect.succeed({ remotes: input.remotes.map((remote) => ({ @@ -82,6 +104,18 @@ function makeRegistry(input: { run: () => Effect.succeed(processOutput("")), ...input.process, }); + const serverSettingsLayer = input.serverSettings + ? Layer.mock(ServerSettings.ServerSettingsService)({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + getProjectSettings: () => Effect.succeed(ServerSettings.emptyProjectSettings), + updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateProjectSettings: () => Effect.succeed(ServerSettings.emptyProjectSettings), + streamChanges: Stream.empty, + ...input.serverSettings, + }) + : ServerSettings.layerTest(input.settings); return SourceControlProviderRegistry.make.pipe( Effect.provide( @@ -92,9 +126,13 @@ function makeRegistry(input: { Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-source-control-registry-test-", - }).pipe(Layer.provide(NodeServices.layer)), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + serverSettingsLayer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), ), ), ); @@ -112,6 +150,87 @@ it.effect("routes GitHub remotes to the GitHub provider", () => }), ); +it.effect("marks automatically detected provider contexts", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], + }); + + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + + assert.strictEqual(handle.contextSource, "detected"); + assert.strictEqual(handle.context?.provider.kind, "github"); + }), +); + +it.effect("uses project-specific remote overrides when a project id is supplied", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-override"); + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], + settings: { + projectSettings: { + [projectId]: { + remoteOverride: { + provider: "gitlab", + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/group/project.git", + }, + }, + }, + }, + }); + + const handle = yield* registry.resolveHandle({ cwd: "/repo", projectId }); + + assert.strictEqual(handle.contextSource, "override"); + assert.strictEqual(handle.provider.kind, "gitlab"); + assert.strictEqual(handle.context?.remoteName, "upstream"); + }), +); + +it.effect("uses a project override when the full settings read is unavailable", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project-secret-failure"); + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], + serverSettings: { + getSettings: Effect.fail( + new ServerSettingsError({ + settingsPath: "/tmp/settings.json", + operation: "read-secret", + providerInstanceId: "claudeAgent", + environmentVariable: "API_KEY", + cause: new Error("secret unavailable"), + }), + ), + getProjectSettings: (requestedProjectId) => + Effect.succeed({ + ...ServerSettings.emptyProjectSettings, + remoteOverride: + requestedProjectId === projectId + ? { + provider: "gitlab", + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/group/project.git", + } + : null, + }), + }, + }); + + const handle = yield* registry.resolveHandle({ cwd: "/repo", projectId }); + + assert.strictEqual(handle.contextSource, "override"); + assert.strictEqual(handle.provider.kind, "gitlab"); + assert.strictEqual(handle.context?.remoteName, "upstream"); + }), +); + +it("returns null for URL hosts that parse as empty strings", () => { + assert.strictEqual(parseRemoteHost("file:///path/to/repo"), null); +}); + it.effect("routes directly by provider kind for remote-first workflows", () => Effect.gen(function* () { const registry = yield* makeRegistry({ @@ -261,3 +380,19 @@ it.effect("falls back to a non-origin remote when origin is not configured", () assert.strictEqual(provider.kind, "azure-devops"); }), ); + +it.effect("prefers a known non-origin provider over an unknown origin remote", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [ + { name: "origin", url: "https://source.example.test/acme/project.git" }, + { name: "upstream", url: "https://github.com/acme/project.git" }, + ], + }); + + const handle = yield* registry.resolveHandle({ cwd: "/repo" }); + + assert.strictEqual(handle.provider.kind, "github"); + assert.strictEqual(handle.context?.remoteName, "upstream"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..db34b8f6629 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -4,7 +4,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import { + type ProjectId, SourceControlProviderError, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; @@ -15,18 +18,24 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import { providerContextFromOverride } from "./RemoteOverride.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; -import { - probeSourceControlProvider, - refineUnknownRemoteProvider, - type SourceControlProviderDiscoverySpec, -} from "./SourceControlProviderDiscovery.ts"; +import { type SourceControlProviderDiscoverySpec } from "./SourceControlProviderDiscovery.ts"; +import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; import { ServerConfig } from "../config.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); +const PROVIDER_DETECTION_CACHE_KEY_SEPARATOR = "\u0000"; + +interface SourceControlProviderResolveInput { + readonly cwd: string; + readonly projectId?: ProjectId | undefined; +} export interface SourceControlProviderRegistration { readonly kind: SourceControlProviderKind; @@ -37,6 +46,7 @@ export interface SourceControlProviderRegistration { export interface SourceControlProviderHandle { readonly provider: SourceControlProvider.SourceControlProvider["Service"]; readonly context: SourceControlProvider.SourceControlProviderContext | null; + readonly contextSource: "override" | "detected" | null; } export class SourceControlProviderRegistry extends Context.Service< @@ -48,12 +58,12 @@ export class SourceControlProviderRegistry extends Context.Service< SourceControlProvider.SourceControlProvider["Service"], SourceControlProviderError >; - readonly resolveHandle: (input: { - readonly cwd: string; - }) => Effect.Effect; - readonly resolve: (input: { - readonly cwd: string; - }) => Effect.Effect< + readonly resolveHandle: ( + input: SourceControlProviderResolveInput, + ) => Effect.Effect; + readonly resolve: ( + input: SourceControlProviderResolveInput, + ) => Effect.Effect< SourceControlProvider.SourceControlProvider["Service"], SourceControlProviderError >; @@ -123,6 +133,17 @@ function unsupportedProvider( }); } +function providerDetectionCacheKey(input: SourceControlProviderResolveInput) { + return `${input.cwd}${PROVIDER_DETECTION_CACHE_KEY_SEPARATOR}${input.projectId ?? ""}`; +} + +function providerDetectionInputFromCacheKey(key: string): SourceControlProviderResolveInput { + const separatorIndex = key.lastIndexOf(PROVIDER_DETECTION_CACHE_KEY_SEPARATOR); + const cwd = separatorIndex === -1 ? key : key.slice(0, separatorIndex); + const projectId = separatorIndex === -1 ? "" : key.slice(separatorIndex + 1); + return projectId ? { cwd, projectId: projectId as ProjectId } : { cwd }; +} + function selectProviderContext( remotes: ReadonlyArray<{ readonly name: string; @@ -141,9 +162,14 @@ function selectProviderContext( } } + const hasKnownProvider = (candidate: SourceControlProvider.SourceControlProviderContext) => + candidate.provider.kind !== "unknown"; return ( + candidates.find( + (candidate) => candidate.remoteName === "origin" && hasKnownProvider(candidate), + ) ?? + candidates.find(hasKnownProvider) ?? candidates.find((candidate) => candidate.remoteName === "origin") ?? - candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? candidates[0] ?? null ); @@ -196,6 +222,8 @@ function bindProviderContext( export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettingsService; const process = yield* VcsProcess.VcsProcess; const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; const providers = new Map< @@ -208,7 +236,34 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( - function* (cwd: string) { + function* (cacheKey: string) { + const input = providerDetectionInputFromCacheKey(cacheKey); + const { cwd, projectId } = input; + + // A settings read failure falls back to remote-based detection instead + // of failing every source-control operation; the warning keeps the + // skipped override visible, and the cache is invalidated on the next + // settings change. + const projectOverride = (overrideProjectId: ProjectId) => + serverSettings.getProjectSettings(overrideProjectId).pipe( + Effect.map((settings) => settings.remoteOverride ?? null), + Effect.tapError((error) => + Effect.logWarning("project remote override lookup skipped: settings unavailable", { + projectId: overrideProjectId, + cause: error, + }), + ), + Effect.orElseSucceed(() => null), + ); + + if (projectId) { + const override = yield* projectOverride(projectId); + const overrideContext = override ? providerContextFromOverride(override) : null; + if (overrideContext) { + return { context: overrideContext, source: "override" as const }; + } + } + const handle = yield* vcsRegistry.resolve({ cwd }).pipe( Effect.mapError( (error) => @@ -221,6 +276,27 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }), ), ); + const repository = yield* handle.driver + .detectRepository(cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + const projectOption = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(cwd) + .pipe( + Effect.flatMap((project) => + Option.isSome(project) || repository === null + ? Effect.succeed(project) + : projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(repository.rootPath), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + if (!projectId && Option.isSome(projectOption)) { + const override = yield* projectOverride(projectOption.value.id); + const overrideContext = override ? providerContextFromOverride(override) : null; + if (overrideContext) { + return { context: overrideContext, source: "override" as const }; + } + } + const remotes = yield* handle.driver.listRemotes(cwd).pipe( Effect.mapError( (error) => @@ -235,32 +311,45 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); const context = selectProviderContext(remotes.remotes); - return yield* refineUnknownRemoteProvider({ + const refinedContext = yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ specs: discoverySpecs, process, cwd, context, }); + return { context: refinedContext, source: refinedContext ? ("detected" as const) : null }; }, ); const providerContextCache = yield* Cache.makeWith< string, - SourceControlProvider.SourceControlProviderContext | null, + { + readonly context: SourceControlProvider.SourceControlProviderContext | null; + readonly source: "override" | "detected" | null; + }, SourceControlProviderError >(detectProviderContext, { capacity: PROVIDER_DETECTION_CACHE_CAPACITY, timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), }); + // Saving a project's remote override must take effect immediately, not + // after the detection cache TTL expires. + yield* Effect.forkScoped( + Stream.runForEach(serverSettings.streamChanges, () => + Cache.invalidateAll(providerContextCache), + ), + ); + const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => - Cache.get(providerContextCache, input.cwd).pipe( - Effect.map((context) => { + Cache.get(providerContextCache, providerDetectionCacheKey(input)).pipe( + Effect.map(({ context, source }) => { const kind = context?.provider.kind ?? "unknown"; const provider = providers.get(kind) ?? unsupportedProvider(kind); return { provider: bindProviderContext(provider, context), context, + contextSource: source, } satisfies SourceControlProviderHandle; }), ); @@ -271,7 +360,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), discover: Effect.all( discoverySpecs.map((spec) => - probeSourceControlProvider({ + SourceControlProviderDiscovery.probeSourceControlProvider({ spec, process, cwd: config.cwd, diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index b6c3d0066df..196bee8b45e 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -9,6 +9,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; +import { isPlatformErrorLike } from "../platformError.ts"; const CodexAuthJsonSchema = Schema.Struct({ tokens: Schema.Struct({ @@ -103,7 +104,7 @@ function isNotFoundError(error: PlatformError.PlatformError): boolean { } const getTelemetryIdentityCauseAnnotations = (cause: unknown) => { - if (cause instanceof PlatformError.PlatformError) { + if (isPlatformErrorLike(cause)) { return { causeKind: "platform", platformReason: cause.reason._tag, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 55aa8f38835..25692b33b1e 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -327,7 +327,7 @@ function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): stri return chunks; } -function parseGitRemoteVerboseOutput( +export function parseGitRemoteVerboseOutput( output: string, ): Map { const remotes = new Map(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7b5956de07f..81b80cca922 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -12,6 +12,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import { isPlatformErrorLike } from "../platformError.ts"; import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -175,11 +176,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { cwd, detail: "Failed to spawn Git process.", }); - if (!(error.cause instanceof PlatformError.PlatformError)) { + if (!isPlatformErrorLike(error.cause)) { return assert.fail("expected the original platform error cause"); } assert.equal(error.cause.reason._tag, "NotFound"); - assert.notInclude(error.detail, error.cause.message); + assert.notInclude(error.detail, error.cause.message ?? ""); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..d2118cffeb4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -29,6 +29,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; +import { isPlatformErrorLike } from "../platformError.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { parseRemoteNames, @@ -339,7 +340,7 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): } function isMissingGitCwdError(error: GitCommandError): boolean { - if (!(error.cause instanceof PlatformError.PlatformError)) { + if (!isPlatformErrorLike(error.cause)) { return false; } @@ -354,7 +355,7 @@ function isMissingGitCwdError(error: GitCommandError): boolean { typeof reason.cause === "object" && reason.cause !== null && "code" in reason.cause && - reason.cause.code === "ENOTDIR" + (reason.cause as { readonly code?: unknown }).code === "ENOTDIR" ); } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ee595b1f836..6e90ef1d27c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -14,12 +14,13 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type { + VcsStatusInput, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitManagerError, ProjectId } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -70,18 +71,21 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + seenInputs?: VcsStatusInput[]; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => + localStatus: (input) => Effect.sync(() => { + state.seenInputs?.push(input); state.localStatusCalls += 1; return state.currentLocalStatus; }), - remoteStatus: (_input, options) => + remoteStatus: (input, options) => Effect.sync(() => { + state.seenInputs?.push(input); state.remoteStatusCalls += 1; state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); return state.currentRemoteStatus; @@ -130,6 +134,157 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("keeps project-aware status cache entries isolated", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + seenInputs: [] as VcsStatusInput[], + }; + const projectId = ProjectId.make("project-one"); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + yield* broadcaster.getStatus({ cwd: "/repo", projectId }); + yield* broadcaster.getStatus({ cwd: "/repo", projectId }); + yield* broadcaster.refreshStatus({ cwd: "/repo", projectId }); + + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.deepStrictEqual(state.seenInputs, [ + { cwd: "/repo", projectId }, + { cwd: "/repo", projectId }, + { cwd: "/repo", projectId }, + { cwd: "/repo", projectId }, + ]); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("propagates a project-scoped refresh to sibling cache entries for the same cwd", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const projectOne = ProjectId.make("project-one"); + const projectTwo = ProjectId.make("project-two"); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + const initialBare = yield* broadcaster.getStatus({ cwd: "/repo" }); + const initialProjectOne = yield* broadcaster.getStatus({ + cwd: "/repo", + projectId: projectOne, + }); + const initialProjectTwo = yield* broadcaster.getStatus({ + cwd: "/repo", + projectId: projectTwo, + }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/project-scoped-refresh", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 4, + }; + const expected = { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }; + + const refreshed = yield* broadcaster.refreshStatus({ cwd: "/repo", projectId: projectOne }); + const cachedBare = yield* broadcaster.getStatus({ cwd: "/repo" }); + const cachedProjectTwo = yield* broadcaster.getStatus({ + cwd: "/repo", + projectId: projectTwo, + }); + + assert.deepStrictEqual(initialBare, baseStatus); + assert.deepStrictEqual(initialProjectOne, baseStatus); + assert.deepStrictEqual(initialProjectTwo, baseStatus); + assert.deepStrictEqual(refreshed, expected); + assert.deepStrictEqual(cachedBare, expected); + assert.deepStrictEqual(cachedProjectTwo, expected); + // Three initial loads plus one refresh fan-out across all three cache keys. + assert.equal(state.localStatusCalls, 6); + assert.equal(state.remoteStatusCalls, 6); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("propagates periodic remote refreshes to sibling project cache entries", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, + }; + const projectOne = ProjectId.make("project-one"); + const projectTwo = ProjectId.make("project-two"); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo", projectId: projectOne }); + yield* broadcaster.getStatus({ cwd: "/repo", projectId: projectTwo }); + + state.currentRemoteStatus = { + ...baseRemoteStatus, + behindCount: 3, + }; + + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo", projectId: projectOne }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)) }, + ), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(snapshotDeferred); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Deferred.await(remoteUpdatedDeferred); + + const siblingStatus = yield* broadcaster.getStatus({ + cwd: "/repo", + projectId: projectTwo, + }); + assert.equal(siblingStatus.behindCount, 3); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [ + undefined, + undefined, + true, + false, + ]); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + it.effect("refreshes the cached snapshot after explicit invalidation", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index b02ca9deb66..a924ef7e1c3 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -27,9 +27,55 @@ import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const VCS_STATUS_CACHE_KEY_SEPARATOR = "\u0000"; const MAX_FAILURE_DIAGNOSTIC_VALUES = 8; const MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH = 128; +type VcsStatusRefreshInput = string | VcsStatusInput; + +interface VcsStatusChange { + readonly key: string; + readonly event: VcsStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedVcsStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +interface StreamStatusOptions { + readonly automaticRemoteRefreshInterval?: Effect.Effect; +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: ( + input: VcsStatusRefreshInput, + ) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + options?: StreamStatusOptions, + ) => Stream.Stream; + } +>()("t3/vcs/VcsStatusBroadcaster") {} + function boundedDiagnosticValue(value: string): string { return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); } @@ -113,30 +159,6 @@ export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { }; } -interface VcsStatusChange { - readonly cwd: string; - readonly event: VcsStatusStreamEvent; -} - -interface CachedValue { - readonly fingerprint: string; - readonly value: T; -} - -interface CachedVcsStatus { - readonly local: CachedValue | null; - readonly remote: CachedValue | null; -} - -interface ActiveRemotePoller { - readonly fiber: Fiber.Fiber; - readonly subscriberCount: number; -} - -interface StreamStatusOptions { - readonly automaticRemoteRefreshInterval?: Effect.Effect; -} - export function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, @@ -151,23 +173,6 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } -export class VcsStatusBroadcaster extends Context.Service< - VcsStatusBroadcaster, - { - readonly getStatus: ( - input: VcsStatusInput, - ) => Effect.Effect; - readonly refreshLocalStatus: ( - cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; - readonly streamStatus: ( - input: VcsStatusInput, - options?: StreamStatusOptions, - ) => Stream.Stream; - } ->()("t3/vcs/VcsStatusBroadcaster") {} - function fingerprintStatusPart(status: unknown): string { return JSON.stringify(status); } @@ -178,6 +183,14 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); +function refreshInputToStatusInput(input: VcsStatusRefreshInput): VcsStatusInput { + return typeof input === "string" ? { cwd: input } : input; +} + +function statusCacheKey(input: VcsStatusInput) { + return `${input.cwd}${VCS_STATUS_CACHE_KEY_SEPARATOR}${input.projectId ?? ""}`; +} + export const make = Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; const fs = yield* FileSystem.FileSystem; @@ -190,23 +203,56 @@ export const make = Effect.gen(function* () { ); const cacheRef = yield* Ref.make(new Map()); const pollersRef = yield* SynchronizedRef.make(new Map()); + const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + + const normalizeStatusInput = Effect.fn("VcsStatusBroadcaster.normalizeStatusInput")(function* ( + input: VcsStatusInput, + ) { + const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + return { ...input, cwd }; + }); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( - cwd: string, + key: string, ) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(key) ?? null)); }); + const cachedSiblingInputsForCwd = Effect.fn("VcsStatusBroadcaster.cachedSiblingInputsForCwd")( + function* (input: VcsStatusInput) { + const refreshedKey = statusCacheKey(input); + const cache = yield* Ref.get(cacheRef); + const inputs: Array = []; + for (const key of cache.keys()) { + if (key === refreshedKey) { + continue; + } + const separatorIndex = key.indexOf(VCS_STATUS_CACHE_KEY_SEPARATOR); + const keyCwd = key.slice(0, separatorIndex); + const projectId = key.slice(separatorIndex + VCS_STATUS_CACHE_KEY_SEPARATOR.length); + if (keyCwd === input.cwd) { + // Keys are only built from validated inputs, so the round-trip preserves the brand. + inputs.push( + projectId + ? { cwd: input.cwd, projectId: projectId as VcsStatusInput["projectId"] } + : { cwd: input.cwd }, + ); + } + } + return inputs; + }, + ); + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + function* (key: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; + const previous = cache.get(key) ?? { local: null, remote: null }; const nextCache = new Map(cache); - nextCache.set(cwd, { + nextCache.set(key, { ...previous, local: nextLocal, }); @@ -215,7 +261,7 @@ export const make = Effect.gen(function* () { if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + key, event: { _tag: "localUpdated", local, @@ -228,15 +274,15 @@ export const make = Effect.gen(function* () { ); const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { + function* (key: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; + const previous = cache.get(key) ?? { local: null, remote: null }; const nextCache = new Map(cache); - nextCache.set(cwd, { + nextCache.set(key, { ...previous, remote: nextRemote, }); @@ -245,7 +291,7 @@ export const make = Effect.gen(function* () { if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + key, event: { _tag: "remoteUpdated", remote, @@ -258,7 +304,7 @@ export const make = Effect.gen(function* () { ); const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, + key: string, local: VcsStatusLocalResult, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }, @@ -272,9 +318,9 @@ export const make = Effect.gen(function* () { value: remote, } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; + const previous = cache.get(key) ?? { local: null, remote: null }; const nextCache = new Map(cache); - nextCache.set(cwd, { + nextCache.set(key, { local: nextLocal, remote: nextRemote, }); @@ -287,7 +333,7 @@ export const make = Effect.gen(function* () { if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + key, event: { _tag: "snapshot", local, @@ -300,47 +346,55 @@ export const make = Effect.gen(function* () { }); const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( - cwd: string, + input: VcsStatusInput, ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + const local = yield* workflow.localStatus(input); + return yield* updateCachedLocalStatus(statusCacheKey(input), local); + }); + + const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( + input: VcsStatusInput, + ) { + const remote = yield* workflow.remoteStatus(input); + return yield* updateCachedRemoteStatus(statusCacheKey(input), remote); }); const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( - cwd: string, + input: VcsStatusInput, ) { - const cached = yield* getCachedStatus(cwd); + const cached = yield* getCachedStatus(statusCacheKey(input)); if (cached?.local) { return cached.local.value; } - return yield* loadLocalStatus(cwd); + return yield* loadLocalStatus(input); }); - const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")(function* ( + input: VcsStatusInput, + ) { + const cached = yield* getCachedStatus(statusCacheKey(input)); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(input); + }); const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( "VcsStatusBroadcaster.getStatus", )(function* (input) { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const cached = yield* getCachedStatus(cwd); - if (cached?.local && cached.remote) { - return mergeGitStatusParts(cached.local.value, cached.remote.value); - } + const normalizedInput = yield* normalizeStatusInput(input); const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], + [getOrLoadLocalStatus(normalizedInput), getOrLoadRemoteStatus(normalizedInput)], { concurrency: "unbounded" }, ); - return yield* updateCachedStatus(cwd, local, remote); + return mergeGitStatusParts(local, remote); }); - const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( - function* (cwd: string) { - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + const refreshLocalStatusForInput = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusForInput")( + function* (input: VcsStatusInput) { + yield* workflow.invalidateLocalStatus(input.cwd); + const local = yield* workflow.localStatus(input); + return yield* updateCachedLocalStatus(statusCacheKey(input), local, { publish: true }); }, ); @@ -348,40 +402,89 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); + const result = yield* refreshLocalStatusForInput({ cwd }); + // Also refresh project-scoped cache entries for this repository, or + // subscribers keyed by projectId keep serving the stale local snapshot. + const siblingInputs = yield* cachedSiblingInputsForCwd({ cwd }); + yield* Effect.forEach( + siblingInputs, + (siblingInput) => + workflow + .localStatus(siblingInput) + .pipe( + Effect.flatMap((local) => + updateCachedLocalStatus(statusCacheKey(siblingInput), local, { publish: true }), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return result; }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( - cwd: string, + input: VcsStatusInput, options?: { readonly refreshUpstream?: boolean }, ) { if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); + yield* workflow.invalidateRemoteStatus(input.cwd); } - const remote = yield* workflow.remoteStatus({ cwd }, options); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + const remote = yield* workflow.remoteStatus(input, options); + const result = yield* updateCachedRemoteStatus(statusCacheKey(input), remote, { + publish: true, + }); + const siblingInputs = yield* cachedSiblingInputsForCwd(input); + yield* Effect.forEach( + siblingInputs, + (siblingInput) => + workflow.remoteStatus(siblingInput, { refreshUpstream: false }).pipe( + Effect.flatMap((siblingRemote) => + updateCachedRemoteStatus(statusCacheKey(siblingInput), siblingRemote, { + publish: true, + }), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return result; + }); + + const refreshStatusForInput = Effect.fn("VcsStatusBroadcaster.refreshStatusForInput")(function* ( + input: VcsStatusInput, + ) { + const [local, remote] = yield* Effect.all( + [workflow.localStatus(input), workflow.remoteStatus(input)], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(statusCacheKey(input), local, remote, { + publish: true, + }); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + )(function* (input) { + const normalizedInput = yield* normalizeStatusInput(refreshInputToStatusInput(input)); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + yield* workflow.invalidateStatus(normalizedInput.cwd); + const result = yield* refreshStatusForInput(normalizedInput); + // A refresh must also update the sibling cache entries (bare-cwd and other + // project-scoped keys) for the same repository, or subscribers under those + // keys keep serving stale status. + const siblingInputs = yield* cachedSiblingInputsForCwd(normalizedInput); + yield* Effect.forEach(siblingInputs, refreshStatusForInput, { + concurrency: "unbounded", + discard: true, + }); + return result; }); const makeRemoteRefreshLoop = ( - cwd: string, + input: VcsStatusInput, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, - ) => { - return Effect.gen(function* () { + ) => + Effect.gen(function* () { const consecutiveFailuresRef = yield* Ref.make(0); const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); const refreshRemoteStatusIfEnabled = Effect.gen(function* () { @@ -394,7 +497,7 @@ export const make = Effect.gen(function* () { return activeInterval; } - const exit = yield* refreshRemoteStatus(cwd, { + const exit = yield* refreshRemoteStatus(input, { refreshUpstream: !Duration.isZero(configuredInterval), }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { @@ -414,7 +517,8 @@ export const make = Effect.gen(function* () { ); const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); yield* Effect.logWarning("VCS remote status refresh failed", { - cwdLength: cwd.length, + cwdLength: input.cwd.length, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), ...remoteRefreshFailureDiagnostics(exit.cause), consecutiveFailures, nextDelayMs: Duration.toMillis(nextDelay), @@ -440,29 +544,29 @@ export const make = Effect.gen(function* () { Effect.asVoid, ); }); - }; const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( - cwd: string, + input: VcsStatusInput, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) { + const key = statusCacheKey(input); yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); + const existing = activePollers.get(key); if (existing) { const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { + nextPollers.set(key, { ...existing, subscriberCount: existing.subscriberCount + 1, }); return Effect.succeed([undefined, nextPollers] as const); } - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( + return makeRemoteRefreshLoop(input, automaticRemoteRefreshInterval, refreshImmediately).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { + nextPollers.set(key, { fiber, subscriberCount: 1, }); @@ -473,17 +577,17 @@ export const make = Effect.gen(function* () { }); const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( - cwd: string, + key: string, ) { const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); + const existing = activePollers.get(key); if (!existing) { return [null, activePollers] as const; } if (existing.subscriberCount > 1) { const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { + nextPollers.set(key, { ...existing, subscriberCount: existing.subscriberCount - 1, }); @@ -491,7 +595,7 @@ export const make = Effect.gen(function* () { } const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); + nextPollers.delete(key); return [existing.fiber, nextPollers] as const; }); @@ -503,19 +607,20 @@ export const make = Effect.gen(function* () { const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { - const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const normalizedInput = yield* normalizeStatusInput(input); + const key = statusCacheKey(normalizedInput); const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(cwd); - const cachedStatus = yield* getCachedStatus(cwd); + const initialLocal = yield* getOrLoadLocalStatus(normalizedInput); + const cachedStatus = yield* getCachedStatus(key); const initialRemote = cachedStatus?.remote?.value ?? null; yield* retainRemotePoller( - cwd, + normalizedInput, options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + const release = releaseRemotePoller(key).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ @@ -524,7 +629,7 @@ export const make = Effect.gen(function* () { remote: initialRemote, }), Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === cwd), + Stream.filter((event) => event.key === key), Stream.map((event) => event.event), ), ).pipe(Stream.ensuring(release)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..cbbf8b050ad 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,7 +42,12 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectDetailsError, ProjectSearchEntriesError, + type ProjectDetectedRemote, + type ProjectEffectiveRemote, + type ProjectRemoteOverride, + type ProjectSettingsPatch, ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, @@ -60,6 +65,7 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -108,6 +114,7 @@ import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import { providerInfoFromOverride } from "./sourceControl/RemoteOverride.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; @@ -314,6 +321,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], [WS_METHODS.projectsListEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], + [WS_METHODS.projectsGetDetails, AuthOrchestrationReadScope], + [WS_METHODS.projectsUpdateSettings, AuthOrchestrationOperateScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], @@ -358,6 +367,67 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], ]); +function detectedRemotesFromGitRemoteVerboseOutput(stdout: string): ProjectDetectedRemote[] { + return [...GitVcsDriver.parseGitRemoteVerboseOutput(stdout).entries()].flatMap(([name, remote]) => + remote.url + ? [ + { + name, + url: remote.url, + ...(remote.pushUrl ? { pushUrl: remote.pushUrl } : {}), + provider: detectSourceControlProviderFromRemoteUrl(remote.url), + }, + ] + : [], + ); +} + +function effectiveRemoteFromOverride( + override: ProjectRemoteOverride, +): ProjectEffectiveRemote | null { + const providerInfo = providerInfoFromOverride(override); + if (!providerInfo) { + return null; + } + return { + source: "override", + provider: override.provider, + remoteName: override.remoteName ?? "origin", + remoteUrl: override.remoteUrl, + ...(override.webUrl ? { webUrl: override.webUrl } : {}), + providerInfo, + }; +} + +function effectiveRemoteFromDetected( + remote: ProjectDetectedRemote | null, +): ProjectEffectiveRemote | null { + if (!remote) { + return null; + } + return { + source: "detected", + provider: remote.provider?.kind ?? "unknown", + remoteName: remote.name, + remoteUrl: remote.url, + providerInfo: remote.provider, + }; +} + +function pickPrimaryRemote(remotes: ReadonlyArray) { + const hasKnownProvider = (remote: ProjectDetectedRemote) => + remote.provider !== null && remote.provider.kind !== "unknown"; + return ( + remotes.find((remote) => remote.name === "origin" && hasKnownProvider(remote)) ?? + remotes.find(hasKnownProvider) ?? + remotes.find((remote) => remote.name === "origin") ?? + remotes[0] ?? + null + ); +} + +const isProjectDetailsError = Schema.is(ProjectDetailsError); + function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -410,6 +480,7 @@ const makeWsRpcLayer = ( const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; + const gitCore = yield* GitVcsDriver.GitVcsDriver; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; @@ -1043,19 +1114,66 @@ const makeWsRpcLayer = ( ); }); - const dispatchNormalizedCommand = ( - normalizedCommand: OrchestrationCommand, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { - const dispatchEffect = - normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) + const validateProjectProviderAccess = ( + command: OrchestrationCommand, + ): Effect.Effect => + Effect.gen(function* () { + if (command.type !== "thread.turn.start") { + return; + } + + const bootstrapProjectId = command.bootstrap?.createThread?.projectId; + const bootstrapModelSelection = command.bootstrap?.createThread?.modelSelection; + const thread = bootstrapProjectId + ? Option.none() + : yield* projectionSnapshotQuery + .getThreadShellById(command.threadId) .pipe( Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + toDispatchCommandError(cause, "Failed to read thread project settings"), ), ); + const projectId = bootstrapProjectId ?? Option.getOrUndefined(thread)?.projectId; + const modelSelection = + command.modelSelection ?? + bootstrapModelSelection ?? + Option.getOrUndefined(thread)?.modelSelection; + if (!projectId || !modelSelection) { + return; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to read project provider settings"), + ), + ); + const disabledProviderInstanceIds = + settings.projectSettings[projectId]?.disabledProviderInstanceIds ?? []; + if (!disabledProviderInstanceIds.includes(modelSelection.instanceId)) { + return; + } + + return yield* new OrchestrationDispatchCommandError({ + message: `Provider instance "${modelSelection.instanceId}" is disabled for this project.`, + }); + }); + + const dispatchNormalizedCommand = ( + normalizedCommand: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + const dispatchEffect = validateProjectProviderAccess(normalizedCommand).pipe( + Effect.flatMap(() => + normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap + ? dispatchBootstrapTurnStart(normalizedCommand) + : orchestrationEngine + .dispatch(normalizedCommand) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ), + ), + ); return startup .enqueueCommand(dispatchEffect) @@ -1105,6 +1223,115 @@ const makeWsRpcLayer = ( .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + const detectProjectGitDetails = (cwd: string) => + Effect.gen(function* () { + const [gitRootResult, branchResult, remoteResult] = yield* Effect.all( + [ + gitCore.execute({ + operation: "projects.getDetails.gitRoot", + cwd, + args: ["rev-parse", "--show-toplevel"], + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }), + gitCore.execute({ + operation: "projects.getDetails.branch", + cwd, + args: ["branch", "--show-current"], + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }), + gitCore.execute({ + operation: "projects.getDetails.remotes", + cwd, + args: ["remote", "-v"], + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }), + ], + { concurrency: "unbounded" }, + ); + const remotes = + remoteResult.exitCode === 0 + ? detectedRemotesFromGitRemoteVerboseOutput(remoteResult.stdout) + : []; + return { + gitRoot: gitRootResult.exitCode === 0 ? gitRootResult.stdout.trim() || null : null, + branch: branchResult.exitCode === 0 ? branchResult.stdout.trim() || null : null, + remotes, + primaryRemote: pickPrimaryRemote(remotes), + }; + }).pipe( + Effect.catch(() => + Effect.succeed({ + gitRoot: null, + branch: null, + remotes: [], + primaryRemote: null, + }), + ), + ); + + const getProjectSettings = (projectId: ProjectId) => + serverSettings.getProjectSettings(projectId); + + const automaticGitFetchIntervalForProject = (projectId: ProjectId | undefined) => + projectId + ? serverSettings.getSettings.pipe( + Effect.map((settings) => { + const projectInterval = + settings.projectSettings[projectId]?.automaticGitFetchInterval; + return projectInterval === null || projectInterval === undefined + ? settings.automaticGitFetchInterval + : Duration.millis(projectInterval); + }), + Effect.catch((cause) => + Effect.logWarning("Failed to read project Git fetch interval setting", { + detail: cause.message, + }).pipe(Effect.flatMap(() => automaticGitFetchInterval)), + ), + ) + : automaticGitFetchInterval; + + const updateProjectSettings = (input: { + readonly projectId: ProjectId; + readonly patch: ProjectSettingsPatch; + }) => + Effect.gen(function* () { + if (input.patch.disabledProviderInstanceIds !== undefined) { + const providers = yield* providerRegistry.getProviders; + const disabledProviderInstanceIds = new Set(input.patch.disabledProviderInstanceIds); + const appEnabledProviders = providers.filter( + (provider) => provider.enabled && provider.availability !== "unavailable", + ); + const hasProjectEnabledProvider = + appEnabledProviders.length === 0 || + appEnabledProviders.some( + (provider) => !disabledProviderInstanceIds.has(provider.instanceId), + ); + + if (!hasProjectEnabledProvider) { + return yield* new ProjectDetailsError({ + message: "At least one provider must stay enabled for this project.", + }); + } + } + + return yield* serverSettings.updateProjectSettings(input.projectId, input.patch); + }).pipe( + Effect.mapError((cause) => + isProjectDetailsError(cause) + ? cause + : new ProjectDetailsError({ + message: "Failed to update project settings.", + cause, + }), + ), + ); + return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( @@ -1615,6 +1842,82 @@ const makeWsRpcLayer = ( "rpc.aggregate": "source-control", }, ), + [WS_METHODS.projectsGetDetails]: (input) => + observeRpcEffect( + WS_METHODS.projectsGetDetails, + Effect.gen(function* () { + const projectOption = yield* projectionSnapshotQuery.getProjectShellById( + input.projectId, + ); + if (Option.isNone(projectOption)) { + return yield* new ProjectDetailsError({ + message: "Project was not found.", + }); + } + + const project = projectOption.value; + const [settings, detected] = yield* Effect.all( + [getProjectSettings(project.id), detectProjectGitDetails(project.workspaceRoot)], + { concurrency: "unbounded" }, + ); + const remote = settings.remoteOverride + ? (effectiveRemoteFromOverride(settings.remoteOverride) ?? + effectiveRemoteFromDetected(detected.primaryRemote)) + : effectiveRemoteFromDetected(detected.primaryRemote); + + return { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + scripts: project.scripts, + settings, + detected, + effective: { + title: project.title, + remote, + }, + }; + }).pipe( + Effect.mapError((cause) => + isProjectDetailsError(cause) + ? cause + : new ProjectDetailsError({ + message: "Failed to load project details.", + cause, + }), + ), + ), + { "rpc.aggregate": "project" }, + ), + [WS_METHODS.projectsUpdateSettings]: (input) => + observeRpcEffect( + WS_METHODS.projectsUpdateSettings, + projectionSnapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.flatMap((projectOption) => + Option.isNone(projectOption) + ? Effect.fail( + new ProjectDetailsError({ + message: "Project was not found.", + }), + ) + : updateProjectSettings({ + projectId: input.projectId, + patch: input.patch, + }), + ), + Effect.mapError((cause) => + isProjectDetailsError(cause) + ? cause + : new ProjectDetailsError({ + message: "Failed to update project settings.", + cause, + }), + ), + ), + { "rpc.aggregate": "project" }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -1747,20 +2050,16 @@ const makeWsRpcLayer = ( observeRpcStream( WS_METHODS.subscribeVcsStatus, vcsStatusBroadcaster.streamStatus(input, { - automaticRemoteRefreshInterval: automaticGitFetchInterval, + automaticRemoteRefreshInterval: automaticGitFetchIntervalForProject(input.projectId), }), { "rpc.aggregate": "vcs", }, ), [WS_METHODS.vcsRefreshStatus]: (input) => - observeRpcEffect( - WS_METHODS.vcsRefreshStatus, - vcsStatusBroadcaster.refreshStatus(input.cwd), - { - "rpc.aggregate": "vcs", - }, - ), + observeRpcEffect(WS_METHODS.vcsRefreshStatus, vcsStatusBroadcaster.refreshStatus(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsPull]: (input) => observeRpcEffect( WS_METHODS.vcsPull, diff --git a/apps/server/test/ws-vitest-interop.ts b/apps/server/test/ws-vitest-interop.ts new file mode 100644 index 00000000000..27e50c252dc --- /dev/null +++ b/apps/server/test/ws-vitest-interop.ts @@ -0,0 +1,34 @@ +import * as NodeModule from "node:module"; + +type WsInterop = { + readonly CONNECTING?: unknown; + readonly OPEN?: unknown; + readonly CLOSING?: unknown; + readonly CLOSED?: unknown; + readonly Receiver?: unknown; + readonly Sender?: unknown; + readonly WebSocket?: unknown; + readonly WebSocketServer?: unknown; + readonly Server?: unknown; + readonly createWebSocketStream?: unknown; + readonly default?: unknown; +}; + +const requireFromPackage = NodeModule.createRequire(import.meta.url); +const requireFromPlatformNodeShared = NodeModule.createRequire( + requireFromPackage.resolve("@effect/platform-node-shared/package.json"), +); +const ws = requireFromPlatformNodeShared("ws") as WsInterop; +const WebSocket = ws.WebSocket ?? ws.default ?? ws; +const WebSocketServer = ws.WebSocketServer ?? ws.Server; + +export const CONNECTING = ws.CONNECTING; +export const OPEN = ws.OPEN; +export const CLOSING = ws.CLOSING; +export const CLOSED = ws.CLOSED; +export const Receiver = ws.Receiver; +export const Sender = ws.Sender; +export const Server = WebSocketServer; +export const createWebSocketStream = ws.createWebSocketStream; +export { WebSocket, WebSocketServer }; +export default WebSocket; diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279..a0bcdd82e23 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,4 +1,5 @@ import "vite-plus/test/config"; +import * as NodeURL from "node:url"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; @@ -63,7 +64,17 @@ export default mergeConfig( ), }, }, + resolve: { + alias: { + ws: NodeURL.fileURLToPath(new URL("./test/ws-vitest-interop.ts", import.meta.url)), + }, + }, test: { + server: { + deps: { + inline: ["@effect/platform-node", "@effect/platform-node-shared", "ws"], + }, + }, // The server suite exercises sqlite, git, temp worktrees, and orchestration // runtimes heavily. Running files in parallel introduces load-sensitive flakes. fileParallelism: false, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 763de56d8c4..40759a7da44 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -226,7 +226,10 @@ export function BranchToolbarBranchSelector({ ? null : vcsEnvironment.status({ environmentId, - input: { cwd: branchCwd }, + input: { + cwd: branchCwd, + ...(activeProject ? { projectId: activeProject.id } : {}), + }, }), ); const trimmedBranchQuery = branchQuery.trim(); @@ -766,15 +769,17 @@ export function BranchToolbarBranchSelector({ ref={branchListRef} data={filteredBranchPickerItems} - keyExtractor={(item) => item} - getItemType={(item) => + keyExtractor={(item: string) => item} + getItemType={(item: string) => item === checkoutPullRequestItemValue ? "checkout-pull-request" : item === createBranchItemValue ? "create-branch" : "branch" } - renderItem={({ item, index }) => renderPickerItem(item, index)} + renderItem={({ item, index }: { item: string; index: number }) => + renderPickerItem(item, index) + } estimatedItemSize={28} drawDistance={336} onEndReached={() => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..73a473919b0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -32,9 +32,11 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; -import ReactMarkdown from "react-markdown"; -import { defaultUrlTransform } from "react-markdown"; +import ReactMarkdown, { + defaultUrlTransform, + type Components as ReactMarkdownComponents, + type Options as ReactMarkdownOptions, +} from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; @@ -234,6 +236,29 @@ type MarkdownAstNode = { children?: MarkdownAstNode[]; }; +type MarkdownNodeWithPosition = { + readonly position?: { + readonly start?: { + readonly offset?: number; + }; + }; +}; + +type MarkdownComponentProps = + React.ComponentPropsWithoutRef & { + readonly node?: MarkdownNodeWithPosition; + }; + +type MarkdownComponents = { + readonly p: (props: MarkdownComponentProps<"p">) => ReactNode; + readonly li: (props: MarkdownComponentProps<"li">) => ReactNode; + readonly input: (props: MarkdownComponentProps<"input">) => ReactNode; + readonly a: (props: MarkdownComponentProps<"a">) => ReactNode; + readonly table: (props: MarkdownComponentProps<"table">) => ReactNode; + readonly details: (props: MarkdownComponentProps<"details">) => ReactNode; + readonly pre: (props: MarkdownComponentProps<"pre">) => ReactNode; +}; + function remarkPreserveCodeMeta() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode) => { @@ -1339,13 +1364,13 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); - const markdownComponents = useMemo( + const markdownComponents = useMemo( () => ({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, li({ node, children, ...props }) { - const listItemStart = node?.position?.start.offset; + const listItemStart = node?.position?.start?.offset; const markerOffset = typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; return ( @@ -1554,7 +1579,7 @@ function ChatMarkdown({ lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS } rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS} - components={markdownComponents} + components={markdownComponents as unknown as ReactMarkdownComponents} urlTransform={markdownUrlTransform} > {text} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..112ed2e835c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -41,7 +41,6 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; -import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { lazy, @@ -67,6 +66,7 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; +import { createDebouncer } from "../lib/debouncer"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { @@ -131,6 +131,7 @@ import { } from "../previewStateStore"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; +import { openPreviewSession } from "./preview/openPreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { RightPanelTabs } from "./RightPanelTabs"; @@ -150,7 +151,7 @@ import { import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { syncProjectScriptKeybinding } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, @@ -300,6 +301,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_ACTION_ENVIRONMENT: Record = {}; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { @@ -1131,9 +1133,6 @@ function ChatViewContent(props: ChatViewProps) { ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); @@ -1809,6 +1808,10 @@ function ChatViewContent(props: ChatViewProps) { const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); + const activeEnvironmentSettings = useMemo( + () => (serverConfig ? { ...settings, ...serverConfig.settings } : settings), + [serverConfig, settings], + ); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2327,7 +2330,7 @@ function ChatViewContent(props: ChatViewProps) { ? null : vcsEnvironment.status({ environmentId, - input: { cwd: gitStatusCwd }, + input: { cwd: gitStatusCwd, ...(activeProject ? { projectId: activeProject.id } : {}) }, }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2373,6 +2376,11 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + const activeProjectActionEnvironment = + activeProject && serverConfig + ? (serverConfig.settings.projectSettings[activeProject.id]?.actionEnvironment ?? + EMPTY_ACTION_ENVIRONMENT) + : EMPTY_ACTION_ENVIRONMENT; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. @@ -2707,7 +2715,10 @@ function ChatViewContent(props: ChatViewProps) { cwd: activeProject.workspaceRoot, }, worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), + extraEnv: { + ...activeProjectActionEnvironment, + ...options?.env, + }, }); const targetTerminalId = shouldCreateNewTerminal ? nextTerminalId(activeKnownTerminalIds) @@ -2762,6 +2773,24 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); + return; + } + + if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { + const previewResult = await openPreviewSession({ + openPreview, + threadRef: activeThreadRef, + url: script.previewUrl, + }); + if (previewResult._tag === "Success") { + useRightPanelStore.getState().openBrowser(activeThreadRef, previewResult.value.tabId); + } else if (!isAtomCommandInterrupted(previewResult)) { + const error = squashAtomCommandFailure(previewResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to open preview for "${script.name}".`, + ); + } } }, [ @@ -2769,6 +2798,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread, activeThreadId, activeThreadRef, + activeProjectActionEnvironment, gitCwd, setTerminalOpen, setThreadError, @@ -2777,6 +2807,7 @@ function ChatViewContent(props: ChatViewProps) { setLastInvokedScriptByProjectId, environmentId, openTerminal, + openPreview, activeKnownTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, @@ -2807,23 +2838,27 @@ function ChatViewContent(props: ChatViewProps) { return updateResult; } - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }); + const keybindingServer = + isElectron && input.keybinding !== undefined ? readLocalApi()?.server : null; + if (isElectron && input.keybinding !== undefined && !keybindingServer) { + return AsyncResult.failure(Cause.fail(new Error("Local API unavailable."))); + } - if (isElectron && keybindingRule) { - return mapAtomCommandResult( - await upsertKeybinding({ - environmentId, - input: keybindingRule, - }), - () => undefined, - ); + const keybindingResult = await settlePromise(() => + syncProjectScriptKeybinding({ + keybindings, + keybinding: input.keybinding, + command: input.keybindingCommand, + server: keybindingServer, + }), + ); + if (keybindingResult._tag === "Failure") { + return keybindingResult; } + return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, keybindings, updateProject], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3382,9 +3417,7 @@ function ChatViewContent(props: ChatViewProps) { // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while // initialScrollAtEnd is settling; hiding is always immediate. - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); + const showScrollDebouncer = useRef(createDebouncer(() => setShowScrollToBottom(true), 150)); const timelineScrollModeRef = useRef("following-end"); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); @@ -5826,7 +5859,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadModelSelection={activeThread?.modelSelection} activeThreadActivities={activeThread?.activities} resolvedTheme={resolvedTheme} - settings={settings} + settings={activeEnvironmentSettings} keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} @@ -5944,6 +5977,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeThread.environmentId} threadId={activeThread.id} cwd={activeProject?.workspaceRoot ?? null} + projectId={activeProject?.id ?? null} initialReference={pullRequestDialogState.initialReference} onOpenChange={(open) => { if (!open) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..db9ba75ca73 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -79,7 +79,11 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { + buildThreadRouteParams, + resolveThreadRouteTarget, + type ThreadRouteTargetParams, +} from "../threadRoutes"; import { applyWslEnvironmentConfiguration, parseWslUncPath, @@ -390,7 +394,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteTarget(params), + select: (params: ThreadRouteTargetParams) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const terminalOpen = useTerminalUiStateStore((state) => diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 3a30fbb2c7e..728104cebf9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -37,7 +37,7 @@ import { import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; -import { resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef, type ThreadRouteRefParams } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -207,7 +207,7 @@ export default function DiffPanel({ const routeThreadRef = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params: ThreadRouteRefParams) => resolveThreadRouteRef(params), }); const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useThread(routeThreadRef); @@ -232,7 +232,7 @@ export default function DiffPanel({ activeThread !== null && activeThread !== undefined && activeCwd != null ? vcsEnvironment.status({ environmentId: activeThread.environmentId, - input: { cwd: activeCwd }, + input: { cwd: activeCwd, ...(activeProjectId ? { projectId: activeProjectId } : {}) }, }) : null, ); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 11716e935ae..77ca7b75054 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -8,6 +8,7 @@ import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, + ProjectId, SourceControlCloneProtocol, SourceControlProviderDiscoveryItem, SourceControlProviderKind, @@ -140,18 +141,19 @@ const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; type RefreshVcsStatus = (target: { readonly environmentId: ScopedThreadRef["environmentId"]; - readonly input: { readonly cwd: string }; + readonly input: { readonly cwd: string; readonly projectId?: ProjectId }; }) => Promise; function requestVcsStatusRefresh( refresh: RefreshVcsStatus, environmentId: ScopedThreadRef["environmentId"] | null, cwd: string | null, + projectId: ProjectId | null, ): void { if (environmentId === null || cwd === null) { return; } - void refresh({ environmentId, input: { cwd } }); + void refresh({ environmentId, input: { cwd, ...(projectId ? { projectId } : {}) } }); } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; @@ -369,6 +371,7 @@ interface PublishRepositoryDialogProps { readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; readonly gitCwd: string; + readonly projectId: ProjectId | null; } function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { @@ -398,8 +401,9 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { () => ({ environmentId: props.environmentId, cwd: props.gitCwd, + projectId: props.projectId, }), - [props.environmentId, props.gitCwd], + [props.environmentId, props.gitCwd, props.projectId], ); const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); const publishAccountByProvider = useMemo(() => { @@ -994,6 +998,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); + const activeProjectId = activeServerThread?.projectId ?? activeDraftThread?.projectId ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -1004,8 +1009,8 @@ export default function GitActionsControl({ useState(null); const activeGitActionProgressRef = useRef(null); const sourceControlScope = useMemo( - () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), - [activeEnvironmentId, gitCwd], + () => ({ environmentId: activeEnvironmentId, cwd: gitCwd, projectId: activeProjectId }), + [activeEnvironmentId, gitCwd, activeProjectId], ); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; @@ -1080,7 +1085,7 @@ export default function GitActionsControl({ activeEnvironmentId !== null && gitCwd !== null ? vcsEnvironment.status({ environmentId: activeEnvironmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, ...(activeProjectId ? { projectId: activeProjectId } : {}) }, }) : null, ); @@ -1189,7 +1194,7 @@ export default function GitActionsControl({ } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd, activeProjectId); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -1208,7 +1213,7 @@ export default function GitActionsControl({ window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); + }, [activeEnvironmentId, activeProjectId, gitCwd, refreshVcsStatus]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); @@ -1726,7 +1731,12 @@ export default function GitActionsControl({ { if (open) { - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + requestVcsStatusRefresh( + refreshVcsStatus, + activeEnvironmentId, + gitCwd, + activeProjectId, + ); } }} > @@ -1986,6 +1996,7 @@ export default function GitActionsControl({ onOpenChange={setIsPublishDialogOpen} environmentId={activeEnvironmentId} gitCwd={gitCwd} + projectId={activeProjectId} /> ; keybindings: ResolvedKeybindingsConfig; + variant?: "toolbar" | "settings"; preferredScriptId?: string | null; - onRunScript: (script: ProjectScript) => void; + onRunScript?: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise; onUpdateScript: ( scriptId: string, @@ -130,6 +132,7 @@ export default function ProjectScriptsControl({ scripts, fileScripts = NO_FILE_SCRIPTS, keybindings, + variant = "toolbar", preferredScriptId = null, onRunScript, onAddScript, @@ -268,6 +271,12 @@ export default function ProjectScriptsControl({ setDialogOpen(true); }; + const openDeleteConfirm = (script: ProjectScript) => { + setEditingScriptId(script.id); + setName(script.name); + setDeleteConfirmOpen(true); + }; + const confirmDeleteScript = useCallback(() => { if (!editingScriptId) return; setDeleteConfirmOpen(false); @@ -326,9 +335,96 @@ export default function ProjectScriptsControl({ ); - return ( - <> - {primaryScript ? ( + const trigger = (() => { + if (variant === "settings") { + return ( +
+
+
Actions
+ {scripts.length === 0 ? ( +
+ No project actions configured. +
+ ) : ( +
+ )} + +
+ {scripts.length > 0 ? ( +
+ {scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand( + keybindings, + commandForProjectScript(script.id), + ); + return ( +
+ + + +
+
+ + {script.name} + + {script.runOnWorktreeCreate ? ( + + Setup + + ) : null} + {shortcutLabel ? ( + + {shortcutLabel} + + ) : null} +
+
+ {script.command} +
+
+
+ + +
+
+ ); + })} +
+ ) : null} +
+ ); + } + + if (primaryScript) { + return ( onRunScript(primaryScript)} + onClick={() => onRunScript?.(primaryScript)} /> } > @@ -365,7 +461,7 @@ export default function ProjectScriptsControl({ onRunScript(script)} + onClick={() => onRunScript?.(script)} > @@ -407,7 +503,11 @@ export default function ProjectScriptsControl({
- ) : importableScripts.length > 0 ? ( + ); + } + + if (importableScripts.length > 0) { + return ( }> @@ -424,21 +524,29 @@ export default function ProjectScriptsControl({ - ) : ( - - - } - > - - - Add action - - - Add action - - )} + ); + } + + return ( + + + } + > + + + Add action + + + Add action + + ); + })(); + + return ( + <> + {trigger} { diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c2..a7859560a0e 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -31,6 +31,7 @@ interface PullRequestThreadDialogProps { environmentId: EnvironmentId; threadId: ThreadId; cwd: string | null; + projectId: ProjectId | null; initialReference: string | null; onOpenChange: (open: boolean) => void; onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; @@ -41,6 +42,7 @@ export function PullRequestThreadDialog({ environmentId, threadId, cwd, + projectId, initialReference, onOpenChange, onPrepared, @@ -59,7 +61,7 @@ export function PullRequestThreadDialog({ ? null : vcsEnvironment.status({ environmentId, - input: { cwd }, + input: { cwd, ...(projectId ? { projectId } : {}) }, }), ); const sourceControlPresentation = useMemo( @@ -86,8 +88,9 @@ export function PullRequestThreadDialog({ () => ({ environmentId, cwd, + projectId, }), - [cwd, environmentId], + [cwd, environmentId, projectId], ); const pullRequestResolution = usePullRequestResolution({ ...sourceControlScope, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac..5e230e75d1f 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -43,6 +43,7 @@ import { type Project, type Thread, } from "../types"; +import { getProjectOrderKey } from "../logicalProject"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -412,12 +413,11 @@ describe("orderItemsByPreferredIds", () => { ]); }); - it("honors projectOrder physical keys via getProjectOrderKey", async () => { + it("honors projectOrder physical keys via getProjectOrderKey", () => { // Regression guard for #1904 / the regression introduced by #2055: // `projectOrder` is populated with physical keys (envId + cwd-derived) // by the store and by drag-end handlers. Readers must identify projects // with the same key format, or manual sort silently snaps back. - const { getProjectOrderKey } = await import("../logicalProject"); const projects = [ { environmentId: EnvironmentId.make("environment-local"), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..1621f39d10e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -62,7 +62,14 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { + Link, + useLocation, + useNavigate, + useParams, + useRouter, + type ParsedLocation, +} from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -411,7 +418,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr thread.branch != null && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, projectId: thread.projectId }, }) : null, ); @@ -1366,14 +1373,22 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/projects/$environmentId/$projectId", + params: { environmentId: project.environmentId, projectId: project.id }, + }); }, [ clearSelection, dragInProgressRef, - projectExpanded, - projectPreferenceKeys, - setProjectExpanded, + isMobile, + project.environmentId, + project.id, + router, + setOpenMobile, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, ], @@ -1382,10 +1397,25 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const handleProjectButtonKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; + if (dragInProgressRef.current) { + event.preventDefault(); + return; + } + }, + [dragInProgressRef], + ); + + const handleProjectToggleClick = useCallback( + (event: React.MouseEvent) => { event.preventDefault(); + event.stopPropagation(); if (dragInProgressRef.current) { return; } + if (suppressProjectClickAfterDragRef.current) { + suppressProjectClickAfterDragRef.current = false; + return; + } setProjectExpanded(projectPreferenceKeys, !projectExpanded); }, [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], @@ -1587,7 +1617,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const actionHandlers = new Map Promise | void>(); const makeLeaf = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "settings" | "rename" | "grouping" | "copy-path" | "delete", member: SidebarProjectGroupMember, options?: { destructive?: boolean; @@ -1597,6 +1627,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const id = `${action}:${member.physicalProjectKey}`; actionHandlers.set(id, () => { switch (action) { + case "settings": + void router.navigate({ + to: "/projects/$environmentId/$projectId", + params: { environmentId: member.environmentId, projectId: member.id }, + }); + return; case "rename": openProjectRenameDialog(member); return; @@ -1620,7 +1656,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; const buildTargetedItem = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "settings" | "rename" | "grouping" | "copy-path" | "delete", label: string, options?: { destructive?: boolean; @@ -1654,6 +1690,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const clicked = await api.contextMenu.show( [ + buildTargetedItem("settings", "Open Settings"), buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), @@ -1677,10 +1714,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, - openProjectGroupingDialog, openProjectRenameDialog, + openProjectGroupingDialog, project.groupedProjectCount, project.memberProjects, + router, suppressProjectClickForContextMenuRef, ], ); @@ -2217,10 +2255,39 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <>
+ - {!projectExpanded && projectStatus ? ( - - - } - > - - - - - - {projectStatus.label} - - ) : ( - - )} @@ -2992,7 +3031,7 @@ export default function Sidebar() { const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); + const pathname = useLocation({ select: (loc: ParsedLocation) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..8c6008be676 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -12,7 +12,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -22,17 +22,15 @@ import { CircleCheckIcon, CircleDashedIcon, ClockIcon, - CopyIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, - EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, ServerIcon, + Settings2Icon, SquarePenIcon, - Trash2Icon, Undo2Icon, } from "lucide-react"; import { @@ -69,14 +67,9 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; -import { - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "../logicalProject"; +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -85,15 +78,13 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; -import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { useClientSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; -import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; @@ -110,7 +101,6 @@ import { resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, - shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, @@ -134,36 +124,17 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr import { primaryServerProvidersAtom } from "../state/server"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; import { Kbd } from "./ui/kbd"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; -import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; -const PROJECT_GROUPING_MODE_LABELS: Record = { - repository: "Group by repository", - repository_path: "Group by repository path", - separate: "Keep separate", -}; - function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; return label.endsWith(" ago") ? label.slice(0, -4) : label; @@ -323,11 +294,11 @@ function SnoozePopoverButton(props: { aria-label="Snooze thread" onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + className="inline-flex h-full cursor-pointer items-center justify-center rounded-md bg-transparent px-1 text-xs text-muted-foreground hover:text-foreground" /> } > - + {presets.map((preset) => ( @@ -852,7 +823,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { role="button" tabIndex={0} data-testid="sidebar-v2-row-card" - className={rowSurfaceClassName} + className={cn(rowSurfaceClassName, "sidebar-v2-thread-card")} onClick={handleClick} onDoubleClick={handleDoubleClick} onKeyDown={handleKeyDown} @@ -861,7 +832,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } >
-
+
)} - + @@ -932,11 +903,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} @@ -1011,34 +983,6 @@ export default function SidebarV2() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { - reportFailure: false, - }); - const updateProject = useAtomCommand(projectEnvironment.update, { - reportFailure: false, - }); - const updateSettings = useUpdateClientSettings(); - const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ - type: "success", - title: "Path copied", - description: path, - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); - const [projectActionsTarget, setProjectActionsTarget] = useState( - null, - ); const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( @@ -1202,156 +1146,20 @@ export default function SidebarV2() { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleRemoveProjectMembers = useCallback( - async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { - const api = readLocalApi(); - if (!api) return; - - const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); - const projectThreads = threads.filter((thread) => - memberKeys.has(`${thread.environmentId}:${thread.projectId}`), - ); - const isWholeGroup = members.length === projectGroup.memberProjects.length; - const singleMember = members.length === 1 ? members[0]! : null; - const targetLabel = singleMember?.title ?? projectGroup.displayName; - const confirmed = await settlePromise(() => - api.dialogs.confirm( - projectThreads.length > 0 - ? [ - `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - "This permanently clears conversation history for those threads.", - isWholeGroup - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${targetLabel}"?`, - ...(singleMember - ? [ - `Path: ${singleMember.workspaceRoot}`, - ...(singleMember.environmentLabel - ? [`Environment: ${singleMember.environmentLabel}`] - : []), - ] - : [`This removes ${members.length} grouped project entries.`]), - isWholeGroup - ? "This removes only the project entries, not the files on disk." - : "Other entries in this grouped project are unaffected.", - ].join("\n"), - ), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - - const draftStore = useComposerDraftStore.getState(); - let shouldNavigate = false; - for (const project of members) { - const memberThreads = projectThreads.filter( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, - ); - const projectRef = scopeProjectRef(project.environmentId, project.id); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ - routeTarget: routeTargetRef.current, - projectThreads: memberThreads, - projectDraftId: projectDraftThread?.draftId ?? null, - }); - - const result = await deleteProject({ - environmentId: project.environmentId, - input: { - projectId: project.id, - ...(memberThreads.length > 0 ? { force: true } : {}), - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${project.title}"`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - if (shouldNavigate) { - void router.navigate({ to: "/" }); - } - return; - } - - shouldNavigate ||= memberRemovalNeedsNavigation; - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); - } - draftStore.clearProjectDraftThreadId(projectRef); - } - - if (shouldNavigate) { - void router.navigate({ to: "/" }); - } - }, - [deleteProject, router, threads], - ); - - const renameProjectMember = useCallback( - async (member: SidebarProjectGroupMember, nextTitle: string) => { - const title = nextTitle.trim(); - if (!title) { - toastManager.add({ type: "warning", title: "Project title cannot be empty" }); - return; - } - if (title === member.title) return; - const result = await updateProject({ - environmentId: member.environmentId, - input: { projectId: member.id, title }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, - [updateProject], - ); - - const updateProjectGroupingPreference = useCallback( - (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { - const overrideKey = deriveProjectGroupingOverrideKey(member); - const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; - if (selection === "inherit") { - delete nextOverrides[overrideKey]; - } else { - nextOverrides[overrideKey] = selection; - } - updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); - }, - [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], - ); - const handleProjectActions = useCallback( (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); setProjectScopeMenuOpen(false); - window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); + void router.navigate({ + to: "/projects/$environmentId/$projectId", + params: { + environmentId: projectGroup.environmentId, + projectId: projectGroup.id, + }, + }); }, - [], + [router], ); // Settled threads stay in the live shell stream (settled ≠ archived), so @@ -2182,7 +1990,7 @@ export default function SidebarV2() { const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); + autoAnimate(node, { duration: 0, easing: "linear" }); }, []); // New thread defaults to the project you're in (active thread's project, @@ -2309,7 +2117,7 @@ export default function SidebarV2() { key={scopeKey} value={scopeKey} closeOnClick - className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" + className="group/project-scope h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" > event.stopPropagation()} onClick={(event) => { void handleProjectActions(event, project); }} > - + ); @@ -2554,178 +2362,6 @@ export default function SidebarV2() { ) : null} - { - if (!open) setProjectActionsTarget(null); - }} - > - - - Project settings - - {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 - ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` - : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} - - - -
- {projectActionsTarget?.memberProjects.map((member) => ( -
-
- -
-
- -

- {member.environmentLabel ?? "Current environment"} -

-
-

- {member.workspaceRoot} -

-
-
-
- - -
-
- - -
-
- ))} -
- {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( -
-
-

- Remove this project everywhere -

-

- Deletes all grouped entries and their conversation history. -

-
- -
- ) : null} -
- - - -
-
); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..620fe47505e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -251,7 +251,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, projectId: thread.projectId }, }) : null, ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..847831484da 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -172,6 +172,7 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveProjectProviderInstancePolicy, resolveProviderDriverKindForInstanceSelection, resolveSelectableProviderInstanceEntry, sortProviderInstanceEntries, @@ -734,13 +735,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Instance-aware projection of the wire provider list. One entry per // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. - const providerInstanceEntries = useMemo>( + const allProviderInstanceEntries = useMemo>( () => sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), ), [providerStatuses, settings], ); + const activeProjectSettings = activeThread?.projectId + ? (settings.projectSettings[activeThread.projectId] ?? null) + : null; + const projectProviderPolicy = useMemo( + () => resolveProjectProviderInstancePolicy(allProviderInstanceEntries, activeProjectSettings), + [activeProjectSettings, allProviderInstanceEntries], + ); + const providerInstanceEntries: ReadonlyArray = + projectProviderPolicy.projectEnabledEntries; const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.session?.providerInstanceId ?? diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..c9446596012 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -122,6 +122,46 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("@pierre/diffs", () => { + function parsePatchFileName(patch: string): string { + const fileHeader = patch + .split("\n") + .find((line) => line.startsWith("+++ ") || line.startsWith("diff --git ")); + if (!fileHeader) return "diff"; + + if (fileHeader.startsWith("+++ ")) { + return fileHeader.replace(/^\+\+\+ (?:b\/)?/, ""); + } + + return fileHeader.split(" ").at(-1)?.replace(/^b\//, "") ?? "diff"; + } + + return { + DiffsHighlighter: undefined, + SupportedLanguages: undefined, + getSharedHighlighter: () => + Promise.resolve({ + codeToHtml: (code: string) => `
${code}
`, + }), + parsePatchFiles: (patch: string, cacheKey?: string) => [ + { + files: [ + { + name: parsePatchFileName(patch), + prevName: parsePatchFileName(patch), + hunks: [], + additionLines: [], + deletionLines: [], + splitLineCount: 0, + unifiedLineCount: 0, + cacheKey, + }, + ], + }, + ], + }; +}); + function matchMedia() { return { matches: false, @@ -139,14 +179,16 @@ beforeAll(async () => { toggle: () => {}, contains: () => false, }; - - vi.stubGlobal("localStorage", { + const localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, - }); + }; + + vi.stubGlobal("localStorage", localStorage); vi.stubGlobal("window", { + localStorage, matchMedia, addEventListener: () => {}, removeEventListener: () => {}, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index bbdbd8bd9d9..a14d9db8c67 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -624,8 +624,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={modelListRef} data={filteredModelKeys} extraData={favoritesSet} - keyExtractor={(modelKey) => modelKey} - renderItem={({ item: modelKey, index }) => { + keyExtractor={(modelKey: string) => modelKey} + renderItem={({ item: modelKey, index }: { item: string; index: number }) => { const model = filteredModelByKey.get(modelKey); if (!model) { return null; diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index de5a2f7cfff..078cc2fad73 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -35,6 +35,8 @@ import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +const FileManagerIcon: Icon = (props) => ; + type OpenInOption = { label: string; Icon: Icon; @@ -170,7 +172,7 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray( () => ({ diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index bc35949280d..e30510ec77a 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -115,12 +115,21 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick: export function SettingsPageContainer({ children, className, + compact = false, }: { children: ReactNode; className?: string; + compact?: boolean; }) { return ( -
+
{children}
diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index d48cda453b8..8840603a2c6 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -29,7 +29,7 @@ import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; import { useComposerDraftStore } from "~/composerDraftStore"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { resolveThreadRouteTarget } from "~/threadRoutes"; +import { resolveThreadRouteTarget, type ThreadRouteTargetParams } from "~/threadRoutes"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent, @@ -430,7 +430,7 @@ interface ToastProviderProps extends Toast.Provider.Props { function useActiveThreadRefFromRoute(): ScopedThreadRef | null { const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteTarget(params), + select: (params: ThreadRouteTargetParams) => resolveThreadRouteTarget(params), }); const activeDraftSession = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..6806eab2fb0 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1185,6 +1185,24 @@ describe("composerDraftStore modelSelection", () => { ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.4")); }); + it("can seed an exact project default without preserving stale draft options", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection( + threadRef, + modelSelection(CODEX_DRIVER, "gpt-5.3-codex", { + reasoningEffort: "high", + }), + ); + store.setModelSelection(threadRef, modelSelection(CODEX_DRIVER, "gpt-5.5"), { + replaceOptions: true, + }); + + expect( + draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[CODEX_INSTANCE], + ).toEqual(modelSelection(CODEX_DRIVER, "gpt-5.5")); + }); + it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 2479d6ba02f..3e8161dd9b7 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -5,6 +5,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { createDefaultModelSelection } from "@t3tools/shared/model"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -23,7 +24,7 @@ import { import { readThreadShell, useProjects, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { primaryServerSettingsAtom } from "../state/server"; -import { resolveThreadRouteTarget } from "../threadRoutes"; +import { resolveThreadRouteTarget, type ThreadRouteTargetParams } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -54,11 +55,11 @@ export function useNewThreadHandler() { }, ): Promise => { const { + applyStickyState, getComposerDraft, getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, - applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, @@ -123,6 +124,33 @@ export function useNewThreadHandler() { if (storedDraftThreadRef && reusableStoredDraftThread === null) { markPromotedDraftThreadByRef(storedDraftThreadRef); } + const projectDefaultModelSelection = project?.defaultModelSelection ?? null; + const seedNewDraftModelState = (draftId: Parameters[0]) => { + if (projectDefaultModelSelection) { + setModelSelection(draftId, projectDefaultModelSelection, { replaceOptions: true }); + } else { + // Without a per-project default, keep the user's sticky selection + // instead of resetting to the canonical default model. + const { stickyModelSelectionByProvider, stickyActiveProvider } = + useComposerDraftStore.getState(); + if ( + stickyActiveProvider === null && + Object.keys(stickyModelSelectionByProvider).length === 0 + ) { + setModelSelection(draftId, createDefaultModelSelection(), { replaceOptions: true }); + } else { + applyStickyState(draftId); + } + } + if (carryModelSelection) { + // The viewed thread's exact selection (model + options like effort + // and context window) wins over the project default or the sticky + // one. replaceOptions: the carried selection is a complete + // snapshot — absent options mean "no options", not "keep whatever + // was just seeded". + setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); + } + }; const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) @@ -196,6 +224,12 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); + if ( + reusableStoredDraftThread.projectId !== projectRef.projectId || + reusableStoredDraftThread.environmentId !== projectRef.environmentId + ) { + seedNewDraftModelState(reusableStoredDraftThread.draftId); + } if ( currentRouteTarget?.kind === "draft" && currentRouteTarget.draftId === reusableStoredDraftThread.draftId @@ -239,6 +273,12 @@ export function useNewThreadHandler() { ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); + if ( + latestActiveDraftThread.projectId !== projectRef.projectId || + latestActiveDraftThread.environmentId !== projectRef.environmentId + ) { + seedNewDraftModelState(currentRouteTarget.draftId); + } return Promise.resolve(); } @@ -262,15 +302,7 @@ export function useNewThreadHandler() { runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); - applyStickyState(draftId); - if (carryModelSelection) { - // After sticky state so the viewed thread's exact selection - // (model + options like effort and context window) wins over the - // globally sticky one. replaceOptions: the carried selection is a - // complete snapshot — absent options mean "no options", not "keep - // whatever sticky state just wrote". - setModelSelection(draftId, carryModelSelection, { replaceOptions: true }); - } + seedNewDraftModelState(draftId); await router.navigate({ to: "/draft/$draftId", @@ -287,7 +319,7 @@ export function useHandleNewThread() { const projectOrder = useUiStateStore((store) => store.projectOrder); const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteTarget(params), + select: (params: ThreadRouteTargetParams) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const activeThread = useThread(routeThreadRef); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 29dd99b8e6d..4d0c26eaa7d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -250,6 +250,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil display: none; } + .sidebar-v2-thread-card { + container: sidebar-v2-thread-card / inline-size; + } + + .sidebar-v2-thread-actions-slot { + min-width: 2.5rem; + } + + .sidebar-v2-settle-label { + display: none; + } + + @container sidebar-v2-thread-card (min-width: 13.5rem) { + .sidebar-v2-thread-actions-slot { + min-width: 4.75rem; + } + + .sidebar-v2-settle-label { + display: inline; + } + } + @media (min-width: 48rem) { @container sidebar-header (min-width: 13.5rem) { .sidebar-brand { diff --git a/apps/web/src/lib/debouncer.ts b/apps/web/src/lib/debouncer.ts new file mode 100644 index 00000000000..9dfdd2fa8a4 --- /dev/null +++ b/apps/web/src/lib/debouncer.ts @@ -0,0 +1,40 @@ +export function createDebouncer>( + fn: (...args: TArgs) => void, + waitMs: number, +) { + let timeout: ReturnType | null = null; + let latestArgs: TArgs | null = null; + + const clearPendingTimeout = () => { + if (timeout === null) return; + globalThis.clearTimeout(timeout); + timeout = null; + }; + + const execute = () => { + const args = latestArgs; + latestArgs = null; + timeout = null; + + if (args !== null) { + fn(...args); + } + }; + + return { + maybeExecute: (...args: TArgs) => { + latestArgs = args; + clearPendingTimeout(); + timeout = globalThis.setTimeout(execute, waitMs); + }, + cancel: () => { + clearPendingTimeout(); + latestArgs = null; + }, + flush: () => { + if (latestArgs === null) return; + clearPendingTimeout(); + execute(); + }, + }; +} diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index cb8318b3d2d..71fb71501e7 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,5 +1,4 @@ -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import type { FileDiffMetadata } from "@pierre/diffs/types"; +import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; export const DIFF_THEME_NAMES = { light: "pierre-light", diff --git a/apps/web/src/lib/projectScriptKeybindings.test.ts b/apps/web/src/lib/projectScriptKeybindings.test.ts index 69e4d77afe0..d83250d6733 100644 --- a/apps/web/src/lib/projectScriptKeybindings.test.ts +++ b/apps/web/src/lib/projectScriptKeybindings.test.ts @@ -1,11 +1,13 @@ import { MAX_KEYBINDING_VALUE_LENGTH, type KeybindingCommand } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { commandForProjectScript } from "../projectScripts"; import { decodeProjectScriptKeybindingRule, keybindingValueForCommand, + keybindingValuesForCommand, PROJECT_SCRIPT_KEYBINDING_INVALID_MESSAGE, + syncProjectScriptKeybinding, } from "./projectScriptKeybindings"; describe("projectScriptKeybindings", () => { @@ -80,4 +82,133 @@ describe("projectScriptKeybindings", () => { expect(value).toBe("mod+shift+k"); }); + + it("reads all matching keybinding values for a command", () => { + const command = commandForProjectScript("test"); + const values = keybindingValuesForCommand( + [ + { + command, + shortcut: { + key: "escape", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + { + command, + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + modKey: true, + }, + }, + ], + command, + ); + + expect(values).toEqual(["mod+esc", "mod+shift+k"]); + }); + + it("removes existing command keybindings when clearing a script keybinding", async () => { + const command = commandForProjectScript("test"); + const server = { + removeKeybinding: vi.fn(async () => ({ keybindings: [], issues: [] })), + upsertKeybinding: vi.fn(async () => ({ keybindings: [], issues: [] })), + }; + + await syncProjectScriptKeybinding({ + keybindings: [ + { + command, + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + { + command, + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ], + keybinding: null, + command, + server, + }); + + expect(server.removeKeybinding).toHaveBeenCalledTimes(2); + expect(server.removeKeybinding).toHaveBeenNthCalledWith(1, { + key: "mod+j", + command, + }); + expect(server.removeKeybinding).toHaveBeenNthCalledWith(2, { + key: "mod+k", + command, + }); + expect(server.upsertKeybinding).not.toHaveBeenCalled(); + }); + + it("skips validation when no keybinding server is available", async () => { + await expect( + syncProjectScriptKeybinding({ + keybindings: [], + keybinding: "k".repeat(MAX_KEYBINDING_VALUE_LENGTH + 1), + command: commandForProjectScript("test"), + server: null, + }), + ).resolves.toBeUndefined(); + }); + + it("removes stale command keybindings before saving the replacement", async () => { + const command = commandForProjectScript("test"); + const server = { + removeKeybinding: vi.fn(async () => ({ keybindings: [], issues: [] })), + upsertKeybinding: vi.fn(async () => ({ keybindings: [], issues: [] })), + }; + + await syncProjectScriptKeybinding({ + keybindings: [ + { + command, + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, + ], + keybinding: "mod+k", + command, + server, + }); + + expect(server.removeKeybinding).toHaveBeenCalledWith({ + key: "mod+j", + command, + }); + expect(server.upsertKeybinding).toHaveBeenCalledWith({ + key: "mod+k", + command, + }); + }); }); diff --git a/apps/web/src/lib/projectScriptKeybindings.ts b/apps/web/src/lib/projectScriptKeybindings.ts index 6f2048f9cd3..3ef17c31a18 100644 --- a/apps/web/src/lib/projectScriptKeybindings.ts +++ b/apps/web/src/lib/projectScriptKeybindings.ts @@ -1,5 +1,6 @@ import { KeybindingRule as KeybindingRuleSchema, + type LocalApi, type KeybindingCommand, type KeybindingRule, type ResolvedKeybindingsConfig, @@ -34,6 +35,44 @@ export function decodeProjectScriptKeybindingRule(input: { return decoded.value; } +type ProjectScriptKeybindingServer = Pick< + LocalApi["server"], + "removeKeybinding" | "upsertKeybinding" +>; + +function keybindingValueFromResolvedRule(binding: ResolvedKeybindingsConfig[number]) { + const parts: string[] = []; + if (binding.shortcut.modKey) parts.push("mod"); + if (binding.shortcut.ctrlKey) parts.push("ctrl"); + if (binding.shortcut.metaKey) parts.push("meta"); + if (binding.shortcut.altKey) parts.push("alt"); + if (binding.shortcut.shiftKey) parts.push("shift"); + const keyToken = + binding.shortcut.key === " " + ? "space" + : binding.shortcut.key === "escape" + ? "esc" + : binding.shortcut.key; + parts.push(keyToken); + return parts.join("+"); +} + +export function keybindingValuesForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, +): string[] { + const values: string[] = []; + const seen = new Set(); + for (const binding of keybindings) { + if (binding.command !== command) continue; + const value = keybindingValueFromResolvedRule(binding); + if (seen.has(value)) continue; + seen.add(value); + values.push(value); + } + return values; +} + export function keybindingValueForCommand( keybindings: ResolvedKeybindingsConfig, command: KeybindingCommand, @@ -41,21 +80,46 @@ export function keybindingValueForCommand( for (let index = keybindings.length - 1; index >= 0; index -= 1) { const binding = keybindings[index]; if (!binding || binding.command !== command) continue; - - const parts: string[] = []; - if (binding.shortcut.modKey) parts.push("mod"); - if (binding.shortcut.ctrlKey) parts.push("ctrl"); - if (binding.shortcut.metaKey) parts.push("meta"); - if (binding.shortcut.altKey) parts.push("alt"); - if (binding.shortcut.shiftKey) parts.push("shift"); - const keyToken = - binding.shortcut.key === " " - ? "space" - : binding.shortcut.key === "escape" - ? "esc" - : binding.shortcut.key; - parts.push(keyToken); - return parts.join("+"); + return keybindingValueFromResolvedRule(binding); } return null; } + +export async function syncProjectScriptKeybinding(input: { + keybindings: ResolvedKeybindingsConfig; + keybinding: string | null | undefined; + command: KeybindingCommand; + server: ProjectScriptKeybindingServer | null | undefined; +}) { + if (input.keybinding === undefined) return; + if (!input.server) return; + + const nextRule = decodeProjectScriptKeybindingRule({ + keybinding: input.keybinding, + command: input.command, + }); + + const existingTargets = keybindingValuesForCommand(input.keybindings, input.command).map( + (key) => ({ + key, + command: input.command, + }), + ); + if (!nextRule) { + if (input.keybinding === null) { + for (const target of existingTargets) { + await input.server.removeKeybinding(target); + } + } + return; + } + + // Persist the new binding before removing the old ones so a failed upsert + // does not leave the command with no binding at all. + await input.server.upsertKeybinding(nextRule); + for (const target of existingTargets) { + if (target.key !== nextRule.key) { + await input.server.removeKeybinding(target); + } + } +} diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index a37c67064aa..4ed9576275f 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -1,4 +1,4 @@ -import { Debouncer } from "@tanstack/react-pacer"; +import { createDebouncer } from "./debouncer"; export interface StateStorage { getItem: (name: string) => string | null | Promise; @@ -44,12 +44,9 @@ export function createDebouncedStorage( debounceMs: number = 300, ): DebouncedStorage { const resolvedStorage = resolveStorage(baseStorage); - const debouncedSetItem = new Debouncer( - (name: string, value: string) => { - resolvedStorage.setItem(name, value); - }, - { wait: debounceMs }, - ); + const debouncedSetItem = createDebouncer((name: string, value: string) => { + resolvedStorage.setItem(name, value); + }, debounceMs); return { getItem: (name) => resolvedStorage.getItem(name), diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 1f7a6bfaa9f..3086a0703c9 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -101,18 +101,36 @@ describe("projectScripts helpers", () => { }); }); - it("allows overriding runtime env values", () => { + it("keeps T3Code runtime env values app-owned", () => { const env = projectScriptRuntimeEnv({ project: { cwd: "/repo" }, + worktreePath: "/repo/worktree-a", extraEnv: { T3CODE_PROJECT_ROOT: "/custom-root", + T3CODE_WORKTREE_PATH: "/custom-worktree", + T3CODE_CUSTOM: "custom", CUSTOM_FLAG: "1", }, }); - expect(env.T3CODE_PROJECT_ROOT).toBe("/custom-root"); + expect(env.T3CODE_PROJECT_ROOT).toBe("/repo"); + expect(env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); + expect(env.T3CODE_CUSTOM).toBeUndefined(); expect(env.CUSTOM_FLAG).toBe("1"); + }); + + it("does not allow extra env to invent T3Code runtime variables", () => { + const env = projectScriptRuntimeEnv({ + project: { cwd: "/repo" }, + extraEnv: { + T3CODE_WORKTREE_PATH: "/custom-worktree", + CUSTOM_FLAG: "1", + }, + }); + + expect(env.T3CODE_PROJECT_ROOT).toBe("/repo"); expect(env.T3CODE_WORKTREE_PATH).toBeUndefined(); + expect(env.CUSTOM_FLAG).toBe("1"); }); it("prefers the worktree path for script cwd resolution", () => { diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index e3efc9e3a33..8664400d266 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -81,7 +81,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable) return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); } -export function primaryProjectScript(scripts: ReadonlyArray): ProjectScript | null { +export function primaryProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; } diff --git a/apps/web/src/projectSettingsCommit.test.ts b/apps/web/src/projectSettingsCommit.test.ts new file mode 100644 index 00000000000..9ed12fd7466 --- /dev/null +++ b/apps/web/src/projectSettingsCommit.test.ts @@ -0,0 +1,113 @@ +import type { ProjectDetails } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + commitProviderSettingsThenDefaultModel, + confirmedProjectSettingsDraftKeys, + type ProjectSettingsDraft, + type ProjectSettingsDraftKey, +} from "./projectSettingsCommit"; + +const projectDetails = (overrides: Partial = {}) => + ({ + id: "project-1", + title: "Project", + workspaceRoot: "/repo/project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + settings: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }, + detected: { + gitRoot: null, + branch: null, + remotes: [], + primaryRemote: null, + }, + effective: { + title: "Project", + remote: null, + }, + ...overrides, + }) as ProjectDetails; + +describe("project settings commit state", () => { + it("keeps optimistic values until refreshed details contain the committed values", () => { + const draft = { + projectKey: "environment-1:project-1", + title: "Renamed project", + automaticGitFetchInterval: 60_000, + } satisfies ProjectSettingsDraft; + const pendingKeys = ["title", "automaticGitFetchInterval"] satisfies ProjectSettingsDraftKey[]; + + expect(confirmedProjectSettingsDraftKeys(draft, projectDetails(), pendingKeys)).toEqual([]); + expect( + confirmedProjectSettingsDraftKeys( + draft, + projectDetails({ + title: "Renamed project", + settings: { + remoteOverride: null, + automaticGitFetchInterval: 60_000, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }, + }), + pendingKeys, + ), + ).toEqual(pendingKeys); + }); + + it("only clears the remote draft fields represented in the optimistic draft", () => { + const draft = { + projectKey: "environment-1:project-1", + overrideEnabled: false, + provider: "github", + remoteName: "origin", + remoteUrl: "https://github.com/acme/project.git", + webUrl: "https://github.com/acme/project", + } satisfies ProjectSettingsDraft; + const pendingKeys = [ + "overrideEnabled", + "provider", + "remoteName", + "remoteUrl", + "webUrl", + ] satisfies ProjectSettingsDraftKey[]; + + expect(confirmedProjectSettingsDraftKeys(draft, projectDetails(), pendingKeys)).toEqual( + pendingKeys, + ); + }); + + it("does not clear the default model when disabling its provider fails", async () => { + const commitDefaultModel = vi.fn(async () => true); + + await expect( + commitProviderSettingsThenDefaultModel(async () => false, commitDefaultModel), + ).resolves.toBe(false); + expect(commitDefaultModel).not.toHaveBeenCalled(); + }); + + it("clears the default model after its provider is disabled successfully", async () => { + const calls: string[] = []; + + await expect( + commitProviderSettingsThenDefaultModel( + async () => { + calls.push("provider"); + return true; + }, + async () => { + calls.push("default-model"); + return true; + }, + ), + ).resolves.toBe(true); + expect(calls).toEqual(["provider", "default-model"]); + }); +}); diff --git a/apps/web/src/projectSettingsCommit.ts b/apps/web/src/projectSettingsCommit.ts new file mode 100644 index 00000000000..36cad5d5f1f --- /dev/null +++ b/apps/web/src/projectSettingsCommit.ts @@ -0,0 +1,79 @@ +import type { + ModelSelection, + ProjectActionEnvironment, + ProjectDetails, + ProviderInstanceId, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +export interface ProjectSettingsDraft { + readonly projectKey: string; + readonly title?: string; + readonly overrideEnabled?: boolean; + readonly provider?: SourceControlProviderKind; + readonly remoteName?: string; + readonly remoteUrl?: string; + readonly webUrl?: string; + readonly defaultModelSelection?: ModelSelection | null; + readonly automaticGitFetchInterval?: number | null; + readonly actionEnvironment?: ProjectActionEnvironment; + readonly disabledProviderInstanceIds?: ProviderInstanceId[]; +} + +export type ProjectSettingsDraftKey = keyof Omit; + +const valuesEqual = (left: unknown, right: unknown) => + JSON.stringify(left) === JSON.stringify(right); + +const draftKeyMatchesDetails = ( + key: ProjectSettingsDraftKey, + draft: ProjectSettingsDraft, + details: ProjectDetails, +) => { + if (!(key in draft)) return true; + + const override = details.settings.remoteOverride; + switch (key) { + case "title": + return draft.title === details.title; + case "overrideEnabled": + return draft.overrideEnabled === Boolean(override); + case "provider": + if (draft.overrideEnabled === false && override === null) return true; + return draft.provider === override?.provider; + case "remoteName": + if (draft.overrideEnabled === false && override === null) return true; + return draft.remoteName === override?.remoteName; + case "remoteUrl": + if (draft.overrideEnabled === false && override === null) return true; + return draft.remoteUrl === override?.remoteUrl; + case "webUrl": + if (draft.overrideEnabled === false && override === null) return true; + return (draft.webUrl || undefined) === override?.webUrl; + case "defaultModelSelection": + return valuesEqual(draft.defaultModelSelection, details.defaultModelSelection); + case "automaticGitFetchInterval": + return draft.automaticGitFetchInterval === details.settings.automaticGitFetchInterval; + case "actionEnvironment": + return valuesEqual(draft.actionEnvironment, details.settings.actionEnvironment); + case "disabledProviderInstanceIds": + return valuesEqual( + draft.disabledProviderInstanceIds, + details.settings.disabledProviderInstanceIds, + ); + } +}; + +export const confirmedProjectSettingsDraftKeys = ( + draft: ProjectSettingsDraft, + details: ProjectDetails, + pendingKeys: Iterable, +) => [...pendingKeys].filter((key) => draftKeyMatchesDetails(key, draft, details)); + +export const commitProviderSettingsThenDefaultModel = async ( + commitProviderSettings: () => Promise, + commitDefaultModel: () => Promise, +) => { + if (!(await commitProviderSettings())) return false; + return commitDefaultModel(); +}; diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 2d0d7bb0213..0fd3b2690e1 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -9,6 +9,7 @@ import { resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, + resolveProjectProviderInstancePolicy, } from "./providerInstances"; function provider(input: { @@ -242,6 +243,40 @@ describe("resolveSelectableProviderInstance", () => { }); }); +describe("resolveProjectProviderInstancePolicy", () => { + it("keeps app-enabled and project-enabled provider lists separate", () => { + const codex = ProviderInstanceId.make("codex"); + const disabledForProject = ProviderInstanceId.make("claudeAgent"); + const globallyDisabled = ProviderInstanceId.make("cursor"); + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("codex"), instanceId: codex }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: disabledForProject, + }), + provider({ + provider: ProviderDriverKind.make("cursor"), + instanceId: globallyDisabled, + enabled: false, + }), + ]); + + const policy = resolveProjectProviderInstancePolicy(entries, { + disabledProviderInstanceIds: [disabledForProject], + }); + + expect(policy.appEnabledEntries.map((entry) => entry.instanceId)).toEqual([ + codex, + disabledForProject, + ]); + expect(policy.projectEnabledEntries.map((entry) => entry.instanceId)).toEqual([codex]); + expect(policy.isProviderAllowed(codex)).toBe(true); + expect(policy.isProviderAllowed(disabledForProject)).toBe(false); + expect(policy.isProviderAllowed(globallyDisabled)).toBe(false); + expect(policy.firstAllowedProvider?.instanceId).toBe(codex); + }); +}); + describe("resolveProviderDriverKindForInstanceSelection", () => { it("maps custom provider instance ids back to their driver kind", () => { const providers = [ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0..2246ba2a65e 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -19,6 +19,7 @@ import { type ModelSelection, type ProviderDriverKind, ProviderInstanceId, + type ProjectSettings, type ServerProvider, type ServerProviderModel, type ServerSettings, @@ -243,6 +244,31 @@ export function sortProviderInstanceEntries( return sorted; } +export interface ProjectProviderInstancePolicy { + readonly appEnabledEntries: ReadonlyArray; + readonly projectEnabledEntries: ReadonlyArray; + readonly isProviderAllowed: (instanceId: ProviderInstanceId) => boolean; + readonly firstAllowedProvider: ProviderInstanceEntry | undefined; +} + +export function resolveProjectProviderInstancePolicy( + entries: ReadonlyArray, + projectSettings: Pick | null | undefined, +): ProjectProviderInstancePolicy { + const disabledInstanceIds = new Set(projectSettings?.disabledProviderInstanceIds ?? []); + const appEnabledEntries = entries.filter((entry) => entry.enabled && entry.isAvailable); + const projectEnabledEntries = appEnabledEntries.filter( + (entry) => !disabledInstanceIds.has(entry.instanceId), + ); + return { + appEnabledEntries, + projectEnabledEntries, + isProviderAllowed: (instanceId) => + projectEnabledEntries.some((entry) => entry.instanceId === instanceId), + firstAllowedProvider: projectEnabledEntries[0], + }; +} + /** * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. diff --git a/apps/web/src/reviewCommentContext.test.ts b/apps/web/src/reviewCommentContext.test.ts index 819c6768ab6..40281c4958f 100644 --- a/apps/web/src/reviewCommentContext.test.ts +++ b/apps/web/src/reviewCommentContext.test.ts @@ -1,4 +1,4 @@ -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { parsePatchFiles } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; import { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 563d1b43755..db5db4c0002 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as SettingsConnectionsRouteImport } from './routes/settings.conne import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' +import { Route as ProjectsEnvironmentIdProjectIdRouteImport } from './routes/projects.$environmentId.$projectId' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -95,6 +96,12 @@ const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ path: '/connect/callback', getParentRoute: () => rootRouteImport, } as any) +const ProjectsEnvironmentIdProjectIdRoute = + ProjectsEnvironmentIdProjectIdRouteImport.update({ + id: '/projects/$environmentId/$projectId', + path: '/projects/$environmentId/$projectId', + getParentRoute: () => rootRouteImport, + } as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -123,6 +130,7 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -140,6 +148,7 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -159,6 +168,7 @@ export interface FileRoutesById { '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -178,6 +188,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/projects/$environmentId/$projectId' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -195,6 +206,7 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/projects/$environmentId/$projectId' id: | '__root__' | '/_chat' @@ -213,6 +225,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/projects/$environmentId/$projectId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -221,6 +234,7 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren ConnectCallbackRoute: typeof ConnectCallbackRoute + ProjectsEnvironmentIdProjectIdRoute: typeof ProjectsEnvironmentIdProjectIdRoute } declare module '@tanstack/react-router' { @@ -323,6 +337,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectCallbackRouteImport parentRoute: typeof rootRouteImport } + '/projects/$environmentId/$projectId': { + id: '/projects/$environmentId/$projectId' + path: '/projects/$environmentId/$projectId' + fullPath: '/projects/$environmentId/$projectId' + preLoaderRoute: typeof ProjectsEnvironmentIdProjectIdRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -386,6 +407,7 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, ConnectCallbackRoute: ConnectCallbackRoute, + ProjectsEnvironmentIdProjectIdRoute: ProjectsEnvironmentIdProjectIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/-authGateRouteContext.ts b/apps/web/src/routes/-authGateRouteContext.ts new file mode 100644 index 00000000000..1049aa713ff --- /dev/null +++ b/apps/web/src/routes/-authGateRouteContext.ts @@ -0,0 +1,14 @@ +import type { resolveInitialServerAuthGateState } from "../environments/primary"; + +export type RouteAuthGateState = + | Awaited> + | { status: "hosted-pairing" } + | { status: "hosted-static" }; + +export interface AuthGateRouteContext { + authGateState: RouteAuthGateState; +} + +export interface AuthGateBeforeLoadArgs { + context: AuthGateRouteContext; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..07a8cbdf158 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -5,6 +5,7 @@ import { Outlet, createRootRoute, type ErrorComponentProps, + type ParsedLocation, useLocation, useNavigate, } from "@tanstack/react-router"; @@ -54,7 +55,7 @@ import { } from "../components/KeybindingsUpdateToast.logic"; export const Route = createRootRoute({ - beforeLoad: async ({ location }) => { + beforeLoad: async ({ location }: { location: ParsedLocation }) => { if (location.pathname === "/pair" && hasHostedPairingRequest(new URL(window.location.href))) { return { authGateState: { @@ -84,7 +85,7 @@ export const Route = createRootRoute({ }); function RootRouteView() { - const pathname = useLocation({ select: (location) => location.pathname }); + const pathname = useLocation({ select: (location: ParsedLocation) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; @@ -278,7 +279,7 @@ function AuthenticatedTracingBootstrap() { function EventRouter() { const navigate = useNavigate(); - const pathname = useLocation({ select: (loc) => loc.pathname }); + const pathname = useLocation({ select: (loc: ParsedLocation) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironment = usePrimaryEnvironment(); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index ce9de113beb..24e85127e1a 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,7 +4,11 @@ import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { + resolveThreadRouteRef, + resolveThreadRouteRenderState, + type ThreadRouteRefParams, +} from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, @@ -18,7 +22,7 @@ import { environmentShell } from "../state/shell"; function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ - select: (params) => resolveThreadRouteRef(params), + select: (params: ThreadRouteRefParams) => resolveThreadRouteRef(params), }); const shell = useEnvironmentQuery( threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..67c678f0773 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -15,6 +15,7 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; +import type { AuthGateBeforeLoadArgs } from "./-authGateRouteContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -184,7 +185,7 @@ function ChatRouteLayout() { } export const Route = createFileRoute("/_chat")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context }: AuthGateBeforeLoadArgs) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" diff --git a/apps/web/src/routes/pair.tsx b/apps/web/src/routes/pair.tsx index 6575cd1bafa..8a1231615af 100644 --- a/apps/web/src/routes/pair.tsx +++ b/apps/web/src/routes/pair.tsx @@ -1,4 +1,5 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import type { AuthGateBeforeLoadArgs } from "./-authGateRouteContext"; import { HostedPairingRouteSurface, @@ -7,7 +8,7 @@ import { } from "../components/auth/PairingRouteSurface"; export const Route = createFileRoute("/pair")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context }: AuthGateBeforeLoadArgs) => { const { authGateState } = context; if (authGateState.status === "hosted-pairing") { return { diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx new file mode 100644 index 00000000000..046803c0fd6 --- /dev/null +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -0,0 +1,1860 @@ +import { ArrowLeftIcon, PlusIcon, RefreshCwIcon, ServerIcon, Trash2Icon } from "lucide-react"; +import { createFileRoute, redirect, useCanGoBack, useNavigate } from "@tanstack/react-router"; +import type { AuthGateBeforeLoadArgs } from "./-authGateRouteContext"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { createDefaultModelSelection, createModelSelection } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; +import { + mapAtomCommandResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { + KeybindingCommand, + ModelSelection, + ProviderInstanceId, + ProjectActionEnvironment, + ProjectEffectiveRemote, + ProjectRemoteOverride, + ProjectScript, + ProjectSettingsPatch, + SidebarProjectGroupingMode, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { DEFAULT_MODEL } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { cn } from "../lib/utils"; +import { ensureLocalApi, readLocalApi } from "../localApi"; +import { Button } from "../components/ui/button"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../components/ui/number-field"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; +import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { Spinner } from "../components/ui/spinner"; +import { Switch } from "../components/ui/switch"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsSection, +} from "../components/settings/settingsLayout"; +import { toastManager, stackedThreadToast } from "../components/ui/toast"; +import { DraftInput } from "../components/ui/draft-input"; +import { isElectron } from "../env"; +import ProjectScriptsControl, { + type NewProjectScriptInput, + type ProjectScriptActionResult, +} from "../components/ProjectScriptsControl"; +import { commandForProjectScript, nextProjectScriptId } from "../projectScripts"; +import { syncProjectScriptKeybinding } from "../lib/projectScriptKeybindings"; +import { + useClientSettings, + usePrimarySettings, + useUpdateClientSettings, +} from "../hooks/useSettings"; +import { + getCustomModelOptionsByInstance, + resolveAppModelSelectionForInstance, +} from "../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveProjectProviderInstancePolicy, + sortProviderInstanceEntries, +} from "../providerInstances"; +import { + commitProviderSettingsThenDefaultModel, + confirmedProjectSettingsDraftKeys, + type ProjectSettingsDraft, + type ProjectSettingsDraftKey, +} from "../projectSettingsCommit"; +import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; +import { TraitsPicker } from "../components/chat/TraitsPicker"; +import { ProviderInstanceIcon } from "../components/chat/ProviderInstanceIcon"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; +import { + EMPTY_SERVER_PROVIDERS, + primaryServerKeybindingsAtom, + primaryServerProvidersAtom, +} from "../state/server"; +import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; + +const PROVIDER_LABELS: Record = { + github: "GitHub", + gitlab: "GitLab", + "azure-devops": "Azure DevOps", + bitbucket: "Bitbucket", + unknown: "Generic", +}; + +const DEFAULT_PROJECT_MODEL_SELECTION = createDefaultModelSelection(); +const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL_MS = Duration.toMillis(Duration.seconds(30)); +const GIT_FETCH_INTERVAL_STEP_SECONDS = 5; +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; + +const EMPTY_ACTION_ENVIRONMENT: ProjectActionEnvironment = {}; +const EMPTY_DISABLED_PROVIDER_INSTANCE_IDS: ProviderInstanceId[] = []; +const ACTION_ENVIRONMENT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ACTION_ENVIRONMENT_RESERVED_PREFIX = "T3CODE_"; + +interface RemoteOverrideDraft { + readonly enabled: boolean; + readonly provider: SourceControlProviderKind; + readonly remoteName: string; + readonly remoteUrl: string; + readonly webUrl: string; +} + +const REMOTE_OVERRIDE_DRAFT_KEYS: readonly ProjectSettingsDraftKey[] = [ + "overrideEnabled", + "provider", + "remoteName", + "remoteUrl", + "webUrl", +]; + +interface ProjectMetaPatch { + readonly title?: string; + readonly defaultModelSelection?: ModelSelection | null; +} + +interface ProjectSettingsCommitState { + readonly projectKey: string | null; + queue: Promise; + inFlight: number; + readonly draftKeysAwaitingRefresh: Set; +} + +const createProjectSettingsCommitState = ( + projectKey: string | null, +): ProjectSettingsCommitState => ({ + projectKey, + queue: Promise.resolve(), + inFlight: 0, + draftKeysAwaitingRefresh: new Set(), +}); + +function draftKeysForSettingsPatch(patch: ProjectSettingsPatch): ProjectSettingsDraftKey[] { + const keys: ProjectSettingsDraftKey[] = []; + if ("remoteOverride" in patch) keys.push(...REMOTE_OVERRIDE_DRAFT_KEYS); + if ("automaticGitFetchInterval" in patch) keys.push("automaticGitFetchInterval"); + if ("actionEnvironment" in patch) keys.push("actionEnvironment"); + if ("disabledProviderInstanceIds" in patch) keys.push("disabledProviderInstanceIds"); + return keys; +} + +function draftKeysForMetaPatch(patch: ProjectMetaPatch): ProjectSettingsDraftKey[] { + const keys: ProjectSettingsDraftKey[] = []; + if ("title" in patch) keys.push("title"); + if ("defaultModelSelection" in patch) keys.push("defaultModelSelection"); + return keys; +} + +function buildRemoteOverride(draft: RemoteOverrideDraft): ProjectRemoteOverride | null { + if (!draft.enabled) return null; + const remoteName = draft.remoteName.trim(); + const remoteUrl = draft.remoteUrl.trim(); + const webUrl = draft.webUrl.trim(); + if (!remoteName || !remoteUrl) return null; + return { + provider: draft.provider, + remoteName, + remoteUrl, + ...(webUrl ? { webUrl } : {}), + }; +} + +function isValidActionEnvironmentKey(key: string): boolean { + return ACTION_ENVIRONMENT_KEY_PATTERN.test(key) && key.length <= 128; +} + +function isReservedActionEnvironmentKey(key: string): boolean { + return ( + key.startsWith(ACTION_ENVIRONMENT_RESERVED_PREFIX) || + key === "__proto__" || + key === "constructor" || + key === "prototype" + ); +} + +function millisecondsToSeconds(milliseconds: number): number { + return Math.round(milliseconds / 1_000); +} + +function normalizeFetchIntervalSeconds(value: number | null): number { + if (value === null || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.round(value)); +} + +function projectScriptActionFailure(message: string): ProjectScriptActionResult { + return AsyncResult.failure(Cause.fail(new Error(message))); +} + +function projectScriptPreviewFields( + input: Pick, +): Pick { + return input.previewUrl + ? { + previewUrl: input.previewUrl, + autoOpenPreview: input.autoOpenPreview, + } + : {}; +} + +function ProjectRouteView() { + const { environmentId, projectId } = Route.useParams(); + const navigate = useNavigate(); + const canGoBack = useCanGoBack(); + const projects = useProjects(); + const threads = useThreadShells(); + const { environments } = useEnvironments(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const updateClientSettings = useUpdateClientSettings(); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const projectGroup = useMemo( + () => + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (candidateEnvironmentId) => + environmentLabelById.get(candidateEnvironmentId) ?? null, + }).find((group) => + group.memberProjects.some( + (member) => member.environmentId === project?.environmentId && member.id === project?.id, + ), + ) ?? null, + [ + environmentLabelById, + primaryEnvironmentId, + project?.environmentId, + project?.id, + projectGroupingSettings, + projects, + ], + ); + const projectLocations = useMemo( + () => + projectGroup?.memberProjects ?? + (project + ? [ + { + ...project, + environmentLabel: environmentLabelById.get(project.environmentId) ?? null, + }, + ] + : []), + [environmentLabelById, project, projectGroup?.memberProjects], + ); + const currentEnvironmentLabel = project + ? (environmentLabelById.get(project.environmentId) ?? "Current environment") + : "Current environment"; + const projectGroupingSelection = project + ? (projectGroupingSettings.sidebarProjectGroupingOverrides?.[ + deriveProjectGroupingOverrideKey(project) + ] ?? "inherit") + : "inherit"; + const primaryProviders = useAtomValue(primaryServerProvidersAtom); + const primaryKeybindings = useAtomValue(primaryServerKeybindingsAtom); + const primarySettings = usePrimarySettings(); + const serverConfigs = useServerConfigs(); + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); + const updateProjectSettings = useAtomCommand(projectEnvironment.updateSettings, { + reportFailure: false, + }); + const projectServerConfig = project?.environmentId + ? (serverConfigs.get(project.environmentId) ?? null) + : null; + const keybindings = + project?.environmentId && project.environmentId !== primaryEnvironmentId + ? (projectServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS) + : primaryKeybindings; + const settings = useMemo( + () => + project?.environmentId && project.environmentId !== primaryEnvironmentId + ? { + ...primarySettings, + ...projectServerConfig?.settings, + } + : primarySettings, + [primaryEnvironmentId, primarySettings, project?.environmentId, projectServerConfig?.settings], + ); + const serverProviders = + project?.environmentId && project.environmentId !== primaryEnvironmentId + ? // Never fall back to the primary environment's providers here: saving + // while this project's config loads would persist instance IDs that do + // not exist on the project's environment. + (projectServerConfig?.providers ?? EMPTY_SERVER_PROVIDERS) + : primaryProviders; + const providerInstanceEntries = useMemo( + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + ), + [serverProviders, settings], + ); + const modelOptionsByInstance = useMemo( + () => getCustomModelOptionsByInstance(settings, serverProviders), + [serverProviders, settings], + ); + const projectDetails = useEnvironmentQuery( + project + ? projectEnvironment.getDetails({ + environmentId: project.environmentId, + input: { projectId: project.id }, + }) + : null, + ); + + const [draft, setDraft] = useState(null); + const [, bumpCommitSettledVersion] = useState(0); + const details = projectDetails.data; + const projectDraftKey = project && details ? `${project.environmentId}:${details.id}` : null; + const currentDraft = draft?.projectKey === projectDraftKey ? draft : null; + const commitStateRef = useRef(createProjectSettingsCommitState(projectDraftKey)); + if (commitStateRef.current.projectKey !== projectDraftKey) { + commitStateRef.current = createProjectSettingsCommitState(projectDraftKey); + } + const override = details?.settings.remoteOverride ?? null; + const title = currentDraft?.title ?? details?.title ?? project?.title ?? ""; + const overrideEnabled = currentDraft?.overrideEnabled ?? Boolean(override); + const provider = + currentDraft?.provider ?? + override?.provider ?? + details?.detected.primaryRemote?.provider?.kind ?? + "unknown"; + const remoteName = + currentDraft?.remoteName ?? + override?.remoteName ?? + details?.detected.primaryRemote?.name ?? + "origin"; + const remoteUrl = + currentDraft?.remoteUrl ?? override?.remoteUrl ?? details?.detected.primaryRemote?.url ?? ""; + const webUrl = + currentDraft?.webUrl ?? + override?.webUrl ?? + details?.detected.primaryRemote?.provider?.baseUrl ?? + ""; + const defaultModelSelection = + currentDraft && "defaultModelSelection" in currentDraft + ? currentDraft.defaultModelSelection + : (details?.defaultModelSelection ?? null); + const automaticGitFetchInterval = + currentDraft && "automaticGitFetchInterval" in currentDraft + ? currentDraft.automaticGitFetchInterval + : (details?.settings.automaticGitFetchInterval ?? null); + const automaticGitFetchIntervalSeconds = millisecondsToSeconds( + automaticGitFetchInterval ?? DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL_MS, + ); + const actionEnvironment = + currentDraft?.actionEnvironment ?? + details?.settings.actionEnvironment ?? + EMPTY_ACTION_ENVIRONMENT; + const disabledProviderInstanceIds = + currentDraft?.disabledProviderInstanceIds ?? + details?.settings.disabledProviderInstanceIds ?? + EMPTY_DISABLED_PROVIDER_INSTANCE_IDS; + const projectProviderPolicy = useMemo( + () => + resolveProjectProviderInstancePolicy(providerInstanceEntries, { + disabledProviderInstanceIds, + }), + [disabledProviderInstanceIds, providerInstanceEntries], + ); + const globallyEnabledProviderInstanceEntries = projectProviderPolicy.appEnabledEntries; + const projectProviderInstanceEntries = projectProviderPolicy.projectEnabledEntries; + const fallbackModelSelection = useMemo(() => { + const entry = + projectProviderInstanceEntries.find( + (candidate) => candidate.enabled && candidate.isAvailable, + ) ?? + projectProviderInstanceEntries[0] ?? + null; + if (!entry) return DEFAULT_PROJECT_MODEL_SELECTION; + const model = + resolveAppModelSelectionForInstance(entry.instanceId, settings, serverProviders, null) ?? + entry.models[0]?.slug ?? + DEFAULT_MODEL; + return { + instanceId: entry.instanceId, + model, + } satisfies ModelSelection; + }, [projectProviderInstanceEntries, serverProviders, settings]); + const stageDraft = useCallback( + (patch: Partial>) => { + if (!projectDraftKey) return; + setDraft((current) => ({ + projectKey: projectDraftKey, + ...(current?.projectKey === projectDraftKey ? current : {}), + ...patch, + })); + }, + [projectDraftKey], + ); + + const showProjectSettingsError = useCallback((title: string, error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, []); + + const refreshProjectDetails = projectDetails.refresh; + + const clearDraftKeys = useCallback( + (keys: Iterable, expectedProjectKey: string | null) => { + setDraft((current) => { + if (!current || current.projectKey !== expectedProjectKey) return current; + const next: Record = { ...current }; + let changed = false; + for (const key of keys) { + if (key in next) { + delete next[key]; + changed = true; + } + } + if (!changed) return current; + // Only projectKey left means nothing is staged anymore. + return Object.keys(next).length <= 1 ? null : (next as unknown as ProjectSettingsDraft); + }); + }, + [], + ); + + const detailsPending = projectDetails.isPending; + const detailsError = projectDetails.error; + useEffect(() => { + setDraft((current) => (current?.projectKey === projectDraftKey ? current : null)); + }, [projectDraftKey]); + + // Only release optimistic fields confirmed by the latest successful + // response. A stale or failed refresh must not reveal older server data. + useEffect(() => { + const state = commitStateRef.current; + if ( + detailsPending || + detailsError || + !details || + !currentDraft || + state.projectKey !== projectDraftKey || + state.inFlight > 0 || + state.draftKeysAwaitingRefresh.size === 0 + ) { + return; + } + const keys = confirmedProjectSettingsDraftKeys( + currentDraft, + details, + state.draftKeysAwaitingRefresh, + ); + for (const key of keys) { + state.draftKeysAwaitingRefresh.delete(key); + } + clearDraftKeys(keys, projectDraftKey); + }, [clearDraftKeys, currentDraft, details, detailsError, detailsPending, projectDraftKey]); + + const runCommit = useCallback((task: (state: ProjectSettingsCommitState) => Promise) => { + const state = commitStateRef.current; + state.inFlight += 1; + const result = state.queue + .catch(() => undefined) + .then(() => task(state)) + .finally(() => { + state.inFlight -= 1; + if (commitStateRef.current === state) { + bumpCommitSettledVersion((version) => version + 1); + } + }); + state.queue = result.then( + () => undefined, + () => undefined, + ); + return result; + }, []); + + const handleCommitFailure = useCallback( + (error: unknown, state: ProjectSettingsCommitState) => { + if (commitStateRef.current !== state) return; + // Drop the optimistic draft so the UI falls back to the persisted + // values instead of showing the rejected edit as saved. + setDraft((current) => (current?.projectKey === state.projectKey ? null : current)); + state.draftKeysAwaitingRefresh.clear(); + refreshProjectDetails(); + showProjectSettingsError("Failed to update project settings", error); + }, + [refreshProjectDetails, showProjectSettingsError], + ); + + const commitProjectMeta = useCallback( + (patch: ProjectMetaPatch) => + runCommit(async (state) => { + if (!project || commitStateRef.current !== state) return false; + const result = await updateProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + ...patch, + }, + }); + if (commitStateRef.current !== state) return false; + if (result._tag === "Failure") { + handleCommitFailure(squashAtomCommandFailure(result), state); + return false; + } + for (const key of draftKeysForMetaPatch(patch)) { + state.draftKeysAwaitingRefresh.add(key); + } + refreshProjectDetails(); + return true; + }), + [handleCommitFailure, project, refreshProjectDetails, runCommit, updateProject], + ); + + const commitProjectSettings = useCallback( + (patch: ProjectSettingsPatch) => + runCommit(async (state) => { + if (!project || commitStateRef.current !== state) return false; + const result = await updateProjectSettings({ + environmentId: project.environmentId, + input: { + projectId: project.id, + patch, + }, + }); + if (commitStateRef.current !== state) return false; + if (result._tag === "Failure") { + handleCommitFailure(squashAtomCommandFailure(result), state); + return false; + } + for (const key of draftKeysForSettingsPatch(patch)) { + state.draftKeysAwaitingRefresh.add(key); + } + refreshProjectDetails(); + return true; + }), + [handleCommitFailure, project, refreshProjectDetails, runCommit, updateProjectSettings], + ); + + const persistRemoteOverrideIfValid = useCallback( + (draft: RemoteOverrideDraft) => { + const nextRemoteOverride = buildRemoteOverride(draft); + if (draft.enabled && nextRemoteOverride === null) return; + void commitProjectSettings({ remoteOverride: nextRemoteOverride }); + }, + [commitProjectSettings], + ); + + const commitTitle = useCallback( + (nextTitle: string) => { + const trimmed = nextTitle.trim(); + if (!details) return; + if (trimmed.length === 0) { + clearDraftKeys(["title"], projectDraftKey); + showProjectSettingsError( + "Failed to update project settings", + new Error("Project name cannot be empty."), + ); + return; + } + // Compare against the displayed (draft-aware) value, not the possibly + // stale server snapshot, so a revert of an in-flight edit still commits. + if (trimmed === title) return; + stageDraft({ title: trimmed }); + void commitProjectMeta({ title: trimmed }); + }, + [ + clearDraftKeys, + commitProjectMeta, + details, + projectDraftKey, + showProjectSettingsError, + stageDraft, + title, + ], + ); + + const updateProjectGroupingPreference = useCallback( + (selection: SidebarProjectGroupingMode | "inherit") => { + if (!project) return; + const overrideKey = deriveProjectGroupingOverrideKey(project); + const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; + if (selection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = selection; + } + updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides }); + }, + [project, projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings], + ); + + const commitDefaultModelSelection = useCallback( + (nextSelection: ModelSelection | null) => { + if (isModelSelectionEqual(nextSelection, defaultModelSelection)) return; + stageDraft({ defaultModelSelection: nextSelection }); + void commitProjectMeta({ defaultModelSelection: nextSelection }); + }, + [commitProjectMeta, defaultModelSelection, stageDraft], + ); + + const commitAutomaticGitFetchInterval = useCallback( + (nextIntervalMs: number | null) => { + if (nextIntervalMs === automaticGitFetchInterval) return; + stageDraft({ automaticGitFetchInterval: nextIntervalMs }); + void commitProjectSettings({ automaticGitFetchInterval: nextIntervalMs }); + }, + [automaticGitFetchInterval, commitProjectSettings, stageDraft], + ); + + const commitActionEnvironment = useCallback( + (nextEnvironment: ProjectActionEnvironment) => { + let normalized: ProjectActionEnvironment; + try { + normalized = normalizeActionEnvironment(nextEnvironment); + } catch (error) { + showProjectSettingsError( + "Failed to update action environment", + error instanceof Error ? error : new Error("Unable to update action environment."), + ); + return; + } + const invalidKey = Object.keys(normalized).find((key) => !isValidActionEnvironmentKey(key)); + if (invalidKey) { + showProjectSettingsError( + "Failed to update action environment", + new Error(`"${invalidKey}" is not a valid environment variable name.`), + ); + return; + } + const reservedKey = Object.keys(normalized).find(isReservedActionEnvironmentKey); + if (reservedKey) { + showProjectSettingsError( + "Failed to update action environment", + new Error(`"${reservedKey}" is a reserved environment variable name.`), + ); + return; + } + if (isStringRecordEqual(normalized, actionEnvironment)) return; + // Stage only after validation passes, and stage the normalized form so + // the UI matches what the server will persist. + stageDraft({ actionEnvironment: normalized }); + void commitProjectSettings({ actionEnvironment: normalized }); + }, + [actionEnvironment, commitProjectSettings, showProjectSettingsError, stageDraft], + ); + + const commitProviderInstanceAllowed = useCallback( + (instanceId: ProviderInstanceId, allowed: boolean) => { + const current = disabledProviderInstanceIds; + const currentSet = new Set(current); + if (!allowed && !currentSet.has(instanceId) && projectProviderInstanceEntries.length <= 1) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "At least one provider is required", + description: "Enable another provider before disabling this one.", + }), + ); + return; + } + if (allowed) { + currentSet.delete(instanceId); + } else { + currentSet.add(instanceId); + } + const knownInstanceIds = new Set( + globallyEnabledProviderInstanceEntries.map((entry) => entry.instanceId), + ); + const nextDisabledProviderInstanceIds = globallyEnabledProviderInstanceEntries + .map((entry) => entry.instanceId) + .filter((id) => currentSet.has(id)) + .concat(current.filter((id) => !knownInstanceIds.has(id))); + stageDraft({ disabledProviderInstanceIds: nextDisabledProviderInstanceIds }); + const commitProviderSettings = () => + commitProjectSettings({ + disabledProviderInstanceIds: nextDisabledProviderInstanceIds, + }); + if (allowed || defaultModelSelection?.instanceId !== instanceId) { + void commitProviderSettings(); + return; + } + // Clear the default only after its provider is confirmed disabled. + void commitProviderSettingsThenDefaultModel(commitProviderSettings, () => { + stageDraft({ defaultModelSelection: null }); + return commitProjectMeta({ defaultModelSelection: null }); + }); + }, + [ + commitProjectMeta, + commitProjectSettings, + defaultModelSelection, + disabledProviderInstanceIds, + globallyEnabledProviderInstanceEntries, + projectProviderInstanceEntries.length, + stageDraft, + ], + ); + + const navigateBackWithinApp = () => { + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }; + + const projectThreadCount = useMemo( + () => + project + ? threads.filter( + (thread) => + thread.projectId === project.id && thread.environmentId === project.environmentId, + ).length + : 0, + [project, threads], + ); + const projectGroupThreadCount = useMemo(() => { + const projectKeys = new Set( + projectLocations.map((location) => `${location.environmentId}\0${location.id}`), + ); + return threads.filter((thread) => + projectKeys.has(`${thread.environmentId}\0${thread.projectId}`), + ).length; + }, [projectLocations, threads]); + const persistProjectScripts = async (input: { + nextScripts: ProjectScript[]; + keybinding?: string | null; + keybindingCommand: KeybindingCommand; + }): Promise => { + if (!project) return projectScriptActionFailure("Project no longer available."); + const updateResult = mapAtomCommandResult( + await updateProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + scripts: input.nextScripts, + }, + }), + () => undefined, + ); + if (updateResult._tag === "Failure") { + return updateResult; + } + + const keybindingResult = await settlePromise(() => + syncProjectScriptKeybinding({ + keybindings, + keybinding: input.keybinding, + command: input.keybindingCommand, + server: readLocalApi()?.server, + }), + ); + refreshProjectDetails(); + if (keybindingResult._tag === "Failure") { + // The script list is already persisted at this point, so report the + // shortcut failure separately instead of failing the whole save — + // retrying the save against stale details would duplicate the script. + showProjectSettingsError( + "Script saved, but updating its keyboard shortcut failed", + Cause.squash(keybindingResult.cause), + ); + } + return updateResult; + }; + + const saveProjectScript = async ( + input: NewProjectScriptInput, + ): Promise => { + const details = projectDetails.data; + if (!details) return projectScriptActionFailure("Project details are not loaded."); + const nextId = nextProjectScriptId( + input.name, + details.scripts.map((script) => script.id), + ); + const nextScript: ProjectScript = { + id: nextId, + name: input.name, + command: input.command, + icon: input.icon, + runOnWorktreeCreate: input.runOnWorktreeCreate, + ...projectScriptPreviewFields(input), + }; + const nextScripts = input.runOnWorktreeCreate + ? [ + ...details.scripts.map((script) => + script.runOnWorktreeCreate + ? Object.assign({}, script, { runOnWorktreeCreate: false }) + : script, + ), + nextScript, + ] + : [...details.scripts, nextScript]; + return persistProjectScripts({ + nextScripts, + keybinding: input.keybinding, + keybindingCommand: commandForProjectScript(nextId), + }); + }; + + const updateProjectScript = async ( + scriptId: string, + input: NewProjectScriptInput, + ): Promise => { + const details = projectDetails.data; + if (!details) return projectScriptActionFailure("Project details are not loaded."); + const existingScript = details.scripts.find((script) => script.id === scriptId); + if (!existingScript) { + return projectScriptActionFailure("Action not found."); + } + const updatedScript: ProjectScript = { + id: existingScript.id, + name: input.name, + command: input.command, + icon: input.icon, + runOnWorktreeCreate: input.runOnWorktreeCreate, + ...projectScriptPreviewFields(input), + }; + const nextScripts = details.scripts.map((script) => + script.id === scriptId + ? updatedScript + : input.runOnWorktreeCreate + ? Object.assign({}, script, { runOnWorktreeCreate: false }) + : script, + ); + return persistProjectScripts({ + nextScripts, + keybinding: input.keybinding, + keybindingCommand: commandForProjectScript(scriptId), + }); + }; + + const deleteProjectScript = async (scriptId: string): Promise => { + const details = projectDetails.data; + if (!details) return projectScriptActionFailure("Project details are not loaded."); + return persistProjectScripts({ + nextScripts: details.scripts.filter((script) => script.id !== scriptId), + keybinding: null, + keybindingCommand: commandForProjectScript(scriptId), + }); + }; + + const [removeProjectPending, setRemoveProjectPending] = useState(false); + const [removeProjectEverywherePending, setRemoveProjectEverywherePending] = useState(false); + const removeProject = useCallback(async () => { + if (!project || removeProjectPending) return; + setRemoveProjectPending(true); + try { + const willDeleteThreads = projectThreadCount > 0; + const message = [ + willDeleteThreads + ? `Remove project "${project.title}" and delete its ${projectThreadCount} thread${ + projectThreadCount === 1 ? "" : "s" + }?` + : `Remove project "${project.title}"?`, + `Path: ${project.workspaceRoot}`, + willDeleteThreads + ? "This permanently clears conversation history for every related thread." + : "This removes only this project entry.", + "This action cannot be undone.", + ].join("\n"); + const confirmed = await ensureLocalApi().dialogs.confirm(message); + if (!confirmed) return; + + const result = await deleteProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + force: true, + }, + }); + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + toastManager.add({ + type: "success", + title: "Project removed", + }); + void navigate({ to: "/" }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to remove project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } finally { + setRemoveProjectPending(false); + } + }, [deleteProject, navigate, project, projectThreadCount, removeProjectPending]); + + const removeProjectEverywhere = useCallback(async () => { + if (projectLocations.length < 2 || removeProjectEverywherePending) return; + setRemoveProjectEverywherePending(true); + try { + const confirmed = await ensureLocalApi().dialogs.confirm( + [ + `Remove all ${projectLocations.length} grouped project entries?`, + projectGroupThreadCount > 0 + ? `This permanently deletes ${projectGroupThreadCount} related thread${ + projectGroupThreadCount === 1 ? "" : "s" + } and their conversation history.` + : "No threads will be deleted.", + "This action cannot be undone.", + ].join("\n"), + ); + if (!confirmed) return; + + const results = await Promise.all( + projectLocations.map((location) => + deleteProject({ + environmentId: location.environmentId, + input: { + projectId: location.id, + force: true, + }, + }), + ), + ); + for (const result of results) { + if (result._tag === "Failure") { + throw squashAtomCommandFailure(result); + } + } + toastManager.add({ + type: "success", + title: "Project removed from all environments", + }); + void navigate({ to: "/" }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to remove grouped project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } finally { + setRemoveProjectEverywherePending(false); + } + }, [ + deleteProject, + navigate, + projectGroupThreadCount, + projectLocations, + removeProjectEverywherePending, + ]); + + const effectiveRemote = projectDetails.data?.effective.remote ?? null; + const defaultModelSelectionAllowed = + defaultModelSelection === null || + projectProviderInstanceEntries.some( + (entry) => entry.instanceId === defaultModelSelection.instanceId, + ); + const displayedModelSelection = + defaultModelSelection && defaultModelSelectionAllowed + ? defaultModelSelection + : fallbackModelSelection; + const displayedModelInstanceEntry = + projectProviderInstanceEntries.find( + (entry) => entry.instanceId === displayedModelSelection.instanceId, + ) ?? null; + const projectIdentityHeader = project ? ( +
+
+

+ {title} +

+ {projectLocations.length > 1 ? ( + + ) : ( +
+ + {currentEnvironmentLabel} +
+ )} +
+
+ ) : null; + return ( + +
+
+ + +
+
Project settings
+
+ +
+ + {project && projectDetails.isPending && !projectDetails.data ? ( + + {projectIdentityHeader} + + + ) : ( + + {!project ? ( + + ) : projectDetails.error !== null ? ( + <> + {projectIdentityHeader} + + + ) : projectDetails.data ? ( + <> + {projectIdentityHeader} + + + + } + /> + { + if ( + value === "inherit" || + value === "repository" || + value === "repository_path" || + value === "separate" + ) { + updateProjectGroupingPreference(value); + } + }} + > + + + {projectGroupingSelection === "inherit" + ? `Default (${PROJECT_GROUPING_MODE_LABELS[projectGroupingSettings.sidebarProjectGroupingMode]})` + : PROJECT_GROUPING_MODE_LABELS[projectGroupingSelection]} + + + + + Use global default + + + {PROJECT_GROUPING_MODE_LABELS.repository} + + + {PROJECT_GROUPING_MODE_LABELS.repository_path} + + + {PROJECT_GROUPING_MODE_LABELS.separate} + + + + } + /> + commitDefaultModelSelection(null)} + /> + ) : null + } + control={ +
+ + commitDefaultModelSelection(createModelSelection(instanceId, model)) + } + /> + {displayedModelInstanceEntry ? ( + {}} + modelOptions={displayedModelSelection.options} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="max-w-32 sm:max-w-36" + onModelOptionsChange={(nextOptions) => + commitDefaultModelSelection( + createModelSelection( + displayedModelSelection.instanceId, + displayedModelSelection.model, + nextOptions, + ), + ) + } + /> + ) : null} +
+ } + /> + } + /> +
+ + + + ) + } + align="start" + resetAction={ + projectDetails.data.settings.remoteOverride !== null ? ( + { + stageDraft({ overrideEnabled: false }); + void commitProjectSettings({ remoteOverride: null }); + }} + /> + ) : null + } + control={ +
+ Custom + { + const enabled = Boolean(checked); + stageDraft({ overrideEnabled: enabled }); + persistRemoteOverrideIfValid({ + enabled, + provider, + remoteName, + remoteUrl, + webUrl, + }); + }} + /> +
+ } + > + {overrideEnabled ? ( +
+ + + + + {buildRemoteOverride({ + enabled: true, + provider, + remoteName, + remoteUrl, + webUrl, + }) === null ? ( +

+ Not saved yet — enter a remote name and URL to apply this override. +

+ ) : null} +
+ ) : null} +
+ + + commitAutomaticGitFetchInterval(null)} + /> + ) : null + } + control={ + + commitAutomaticGitFetchInterval( + Duration.toMillis( + Duration.seconds(normalizeFetchIntervalSeconds(value)), + ), + ) + } + > + + + + + + + } + /> +
+ + + {globallyEnabledProviderInstanceEntries.map((entry) => { + const allowed = !disabledProviderInstanceIds.includes(entry.instanceId); + const isLastAllowedProvider = + allowed && projectProviderInstanceEntries.length <= 1; + const duplicateDriverCount = globallyEnabledProviderInstanceEntries.filter( + (candidate) => candidate.driverKind === entry.driverKind, + ).length; + return ( +
+
+ 1} + className="size-7" + iconClassName="size-6" + badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" + /> +
+
+ {entry.displayName} +
+
+ {entry.instanceId} +
+
+
+ + commitProviderInstanceAllowed(entry.instanceId, Boolean(checked)) + } + /> +
+ ); + })} +
+ + +
+ +
+ {Object.keys(actionEnvironment).length === 0 ? ( + + ) : ( + + + + )} +
+ +
+
+
Remove project
+
+ {projectThreadCount > 0 + ? "All project threads will be deleted." + : "No threads will be deleted."} +
+
+ +
+ {projectLocations.length > 1 ? ( +
+
+
+ Remove project everywhere +
+
+ Deletes all {projectLocations.length} grouped entries and their conversation + history. +
+
+ +
+ ) : null} + + ) : null} +
+ )} +
+
+ ); +} + +function isModelSelectionEqual(left: ModelSelection | null, right: ModelSelection | null) { + return JSON.stringify(left) === JSON.stringify(right); +} + +function normalizeActionEnvironment( + environment: Readonly>, +): ProjectActionEnvironment { + const normalized = new Map(); + for (const [key, value] of Object.entries(environment)) { + const trimmed = key.trim(); + if (trimmed.length === 0) continue; + if (normalized.has(trimmed)) { + throw new Error(`Duplicate action environment key "${trimmed}".`); + } + normalized.set(trimmed, value); + } + return Object.fromEntries( + [...normalized.entries()].toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + +function isStringRecordEqual( + left: Readonly>, + right: Readonly>, +) { + return ( + JSON.stringify(normalizeActionEnvironment(left)) === + JSON.stringify(normalizeActionEnvironment(right)) + ); +} + +function ProjectPathLink({ path }: { path: string }) { + const openPath = () => { + const api = readLocalApi(); + void api?.shell.openInEditor(path, "file-manager").catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open project folder", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }; + + return ( + + ); +} + +function ProjectSettingsLoading() { + return ( +
+ +
+ ); +} + +function RemoteSettingDescription({ remote }: { remote: ProjectEffectiveRemote | null }) { + if (!remote) { + return ( +
+ No Git remote configured. + Enable custom remote to set one manually. +
+ ); + } + + const remoteValue = formatEffectiveGitRemoteValue(remote); + const openUrl = remote.webUrl; + const content = ( + + {remote.remoteName}: + + {remoteValue} + + + ); + + if (!openUrl) return
{content}
; + + return ( + + ); +} + +function formatEffectiveGitRemoteValue(remote: ProjectEffectiveRemote) { + return remote.remoteUrl; +} + +function ProjectSettingRow({ + title, + description, + value, + control, + resetAction, + children, + align = "center", +}: { + title: string; + description?: ReactNode; + value?: string; + control?: ReactNode; + resetAction?: ReactNode; + children?: ReactNode; + align?: "center" | "start"; +}) { + const hasChildren = Boolean(children); + const alignStart = align === "start" || hasChildren || description !== undefined; + return ( +
+
+
+
+
{title}
+ + {resetAction} + +
+ {description ? ( +
+ {description} +
+ ) : null} +
+
+ {control ? ( +
+ {control} +
+ ) : value !== undefined ? ( +
+ {value} +
+ ) : null} +
+
+ {children ?
{children}
: null} +
+ ); +} + +function ActionEnvironmentEditor({ + environment, + onChange, +}: { + environment: ProjectActionEnvironment; + onChange: (environment: ProjectActionEnvironment) => void; +}) { + const entries = useMemo( + () => Object.entries(environment).toSorted(([left], [right]) => left.localeCompare(right)), + [environment], + ); + + const updateEntryKey = (previousKey: string, nextKey: string) => { + const trimmedNextKey = nextKey.trim(); + // Clearing the name field is a no-op rather than a silent row drop: + // normalizeActionEnvironment would discard an empty key on commit while + // the row kept rendering as if it were saved. + if (trimmedNextKey.length === 0 || trimmedNextKey === previousKey) return; + const duplicateKey = Object.keys(environment).find( + (key) => key !== previousKey && key.trim() === trimmedNextKey, + ); + if (duplicateKey) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update action environment", + description: `"${trimmedNextKey}" is already configured.`, + }), + ); + return; + } + if (isReservedActionEnvironmentKey(trimmedNextKey)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update action environment", + description: `"${trimmedNextKey}" is a reserved environment variable name.`, + }), + ); + return; + } + + const next = { ...environment }; + const value = next[previousKey] ?? ""; + delete next[previousKey]; + next[trimmedNextKey] = value; + onChange(next); + }; + + const updateEntryValue = (key: string, value: string) => { + onChange({ ...environment, [key]: value }); + }; + + const removeEntry = (key: string) => { + const next = { ...environment }; + delete next[key]; + onChange(next); + }; + + const addEntry = () => { + let index = 1; + let key = "VARIABLE"; + while (Object.prototype.hasOwnProperty.call(environment, key)) { + index += 1; + key = `VARIABLE_${index}`; + } + onChange({ ...environment, [key]: "" }); + }; + + if (entries.length === 0) { + return ( +
+
Variables
+
+ No action variables configured. +
+ +
+ ); + } + + return ( +
+
+ {entries.map(([key, value]) => ( +
+ updateEntryKey(key, nextKey)} + /> + updateEntryValue(key, nextValue)} + /> + +
+ ))} +
+
+ +
+
+ ); +} + +function ProjectNotice({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} + +function openExternalUrl(url: string, title: string) { + const api = readLocalApi(); + void api?.shell.openExternal(url).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); +} + +export const Route = createFileRoute("/projects/$environmentId/$projectId")({ + beforeLoad: async ({ context }: AuthGateBeforeLoadArgs) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: ProjectRouteView, +}); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index a9d05d3d442..128046c3f9c 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -3,11 +3,13 @@ import { Outlet, createFileRoute, redirect, + type ParsedLocation, useCanGoBack, useLocation, useNavigate, } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; +import type { AuthGateBeforeLoadArgs } from "./-authGateRouteContext"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; @@ -114,7 +116,10 @@ function SettingsRouteLayout() { } export const Route = createFileRoute("/settings")({ - beforeLoad: async ({ context, location }) => { + beforeLoad: async ({ + context, + location, + }: AuthGateBeforeLoadArgs & { location: ParsedLocation }) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 3271eefd1e1..1071d8209df 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -33,7 +33,7 @@ interface PrimaryServerState { } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { config: null, latestEvent: null, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..a223726abf5 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -13,6 +13,7 @@ import type { GitActionProgressEvent, GitResolvePullRequestResult, GitStackedAction, + ProjectId, SourceControlCloneProtocol, SourceControlRepositoryVisibility, ThreadId, @@ -39,6 +40,7 @@ export type SourceControlActionKind = export interface SourceControlActionScope { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; + readonly projectId?: ProjectId | null; } interface SourceControlActionState< @@ -123,6 +125,14 @@ function resolveScope(scope: SourceControlActionScope) { return { environmentId: scope.environmentId, cwd: scope.cwd, + projectId: scope.projectId ?? null, + }; +} + +function statusInputForScope(scope: SourceControlActionScope) { + return { + cwd: scope.cwd!, + ...(scope.projectId ? { projectId: scope.projectId } : {}), }; } @@ -167,7 +177,7 @@ export function useVcsPullAction(scope: SourceControlActionScope) { scope.environmentId !== null && scope.cwd !== null ? vcsEnvironment.status({ environmentId: scope.environmentId, - input: { cwd: scope.cwd }, + input: statusInputForScope(scope), }) : null, ); @@ -206,7 +216,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { scope.environmentId !== null && scope.cwd !== null ? vcsEnvironment.status({ environmentId: scope.environmentId, - input: { cwd: scope.cwd }, + input: statusInputForScope(scope), }) : null, ); @@ -234,6 +244,7 @@ export function useGitStackedAction(scope: SourceControlActionScope) { return runStackedAction({ actionId: input.actionId, action: input.action, + ...(scope.projectId ? { projectId: scope.projectId } : {}), ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), @@ -261,7 +272,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi scope.environmentId !== null && scope.cwd !== null ? vcsEnvironment.status({ environmentId: scope.environmentId, - input: { cwd: scope.cwd }, + input: statusInputForScope(scope), }) : null, ); @@ -326,6 +337,7 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop environmentId: target.environmentId, input: { cwd: target.cwd, + ...(target.projectId ? { projectId: target.projectId } : {}), reference: input.reference, mode: input.mode, ...(input.threadId ? { threadId: input.threadId } : {}), @@ -345,9 +357,22 @@ export function usePreparePullRequestThreadAction(scope: SourceControlActionScop export interface PullRequestResolutionTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; + readonly projectId?: ProjectId | null; readonly reference: string | null; } +function pullRequestResolutionInput(target: { + readonly cwd: string; + readonly projectId?: ProjectId | null | undefined; + readonly reference: string; +}) { + return { + cwd: target.cwd, + ...(target.projectId ? { projectId: target.projectId } : {}), + reference: target.reference, + }; +} + export function readCachedPullRequestResolution( target: PullRequestResolutionTarget, ): GitResolvePullRequestResult | null { @@ -359,7 +384,11 @@ export function readCachedPullRequestResolution( appAtomRegistry.get( gitEnvironment.pullRequestResolution({ environmentId: target.environmentId, - input: { cwd: target.cwd, reference: target.reference }, + input: pullRequestResolutionInput({ + cwd: target.cwd, + projectId: target.projectId, + reference: target.reference, + }), }), ), ), @@ -371,10 +400,11 @@ export function usePullRequestResolutionState(target: PullRequestResolutionTarge target.environmentId !== null && target.cwd !== null && target.reference !== null ? gitEnvironment.pullRequestResolution({ environmentId: target.environmentId, - input: { + input: pullRequestResolutionInput({ cwd: target.cwd, + projectId: target.projectId, reference: target.reference, - }, + }), }) : null, ); diff --git a/apps/web/src/state/use-atom-command.ts b/apps/web/src/state/use-atom-command.ts index 37ce280e9f4..6d5cf20f14f 100644 --- a/apps/web/src/state/use-atom-command.ts +++ b/apps/web/src/state/use-atom-command.ts @@ -5,13 +5,14 @@ import { type AtomCommandResult, runAtomCommand, } from "@t3tools/client-runtime/state/runtime"; +import type { AtomRegistry } from "effect/unstable/reactivity/AtomRegistry"; import { useCallback, useContext } from "react"; export function useAtomCommand( command: AtomCommand, options?: string | AtomCommandOptions, ): (value: W) => Promise> { - const registry = useContext(RegistryContext); + const registry = useContext(RegistryContext) as AtomRegistry; const label = typeof options === "string" ? options : (options?.label ?? command.label); const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); diff --git a/apps/web/src/state/use-atom-query-runner.ts b/apps/web/src/state/use-atom-query-runner.ts index 22f971e09a5..a247d0a52c3 100644 --- a/apps/web/src/state/use-atom-query-runner.ts +++ b/apps/web/src/state/use-atom-query-runner.ts @@ -4,6 +4,7 @@ import { type AtomCommandOptions, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import type { AtomRegistry } from "effect/unstable/reactivity/AtomRegistry"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; import { useCallback, useContext } from "react"; @@ -11,7 +12,7 @@ export function useAtomQueryRunner( family: (target: T) => Atom.Atom>, options?: string | AtomCommandOptions, ): (target: T) => Promise> { - const registry = useContext(RegistryContext); + const registry = useContext(RegistryContext) as AtomRegistry; const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index fd5bc39d836..8e06b456c42 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -55,9 +55,15 @@ export function buildDraftThreadRouteParams(draftId: DraftId): { return { draftId }; } -export function resolveThreadRouteRef( - params: Partial>, -): ScopedThreadRef | null { +export type ThreadRouteRefParams = Partial< + Record<"environmentId" | "threadId", string | undefined> +>; + +export type ThreadRouteTargetParams = Partial< + Record<"environmentId" | "threadId" | "draftId", string | undefined> +>; + +export function resolveThreadRouteRef(params: ThreadRouteRefParams): ScopedThreadRef | null { if (!params.environmentId || !params.threadId) { return null; } @@ -66,7 +72,7 @@ export function resolveThreadRouteRef( } export function resolveThreadRouteTarget( - params: Partial>, + params: ThreadRouteTargetParams, ): ThreadRouteTarget | null { if (params.environmentId && params.threadId) { return { diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5d744d540a5..3c4f6db28a4 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,5 @@ -import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; +import { createDebouncer } from "./lib/debouncer"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; @@ -220,7 +220,7 @@ export function persistState(state: UiState): void { } } -const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); +const debouncedPersistState = createDebouncer(persistState, 500); export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { const visitedAtMs = Date.parse(visitedAt); diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..53a2935f37f 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -66,6 +66,11 @@ export function createProjectEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), + getDetails: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:get-details", + tag: WS_METHODS.projectsGetDetails, + staleTimeMs: 15_000, + }), readFile: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:read-file", tag: WS_METHODS.projectsReadFile, @@ -102,5 +107,11 @@ export function createProjectEnvironmentAtoms( JSON.stringify([environmentId, input.cwd, input.relativePath]), }, }), + updateSettings: createEnvironmentRpcCommand(runtime, { + label: "environment-data:projects:update-settings", + tag: WS_METHODS.projectsUpdateSettings, + scheduler: projectScheduler, + concurrency: projectConcurrency, + }), }; } diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f2cba39cf9d..2915238b5bc 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -6,6 +6,7 @@ import { type GitRunStackedActionInput, type GitRunStackedActionResult, GitStackedAction, + type ProjectId as ProjectIdType, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -71,6 +72,7 @@ export interface BeginVcsActionInput { export interface RunVcsStackedActionInput { readonly actionId: string; readonly action: GitStackedAction; + readonly projectId?: ProjectIdType | null; readonly commitMessage?: string; readonly featureBranch?: boolean; readonly filePaths?: ReadonlyArray; @@ -177,6 +179,9 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; } + // Keyed by environment + worktree only: all VCS operations against the same + // repository must share one serial lock, regardless of which project view + // initiated them. return JSON.stringify([target.environmentId, target.cwd]); } @@ -457,6 +462,10 @@ export function createVcsActionManager( const rpcInput: GitRunStackedActionInput = { actionId: transportActionId, cwd: target.cwd, + // The command is cached per [environment, cwd], so the project + // context must come from each invocation, never from the target + // captured when the command was first created. + ...(input.projectId ? { projectId: input.projectId } : {}), action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts index 0a301fa86f3..bb581dbc065 100644 --- a/packages/client-runtime/src/state/vcsStatus.ts +++ b/packages/client-runtime/src/state/vcsStatus.ts @@ -1,6 +1,7 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; export interface VcsStatusTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; + readonly projectId?: ProjectId | null; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index aa5cdf8432b..8802e4e8c35 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,11 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + NonNegativeInt, + PositiveInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; @@ -101,6 +107,7 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; export const VcsStatusInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + projectId: Schema.optional(ProjectId), }); export type VcsStatusInput = typeof VcsStatusInput.Type; @@ -112,6 +119,7 @@ export type VcsPullInput = typeof VcsPullInput.Type; export const GitRunStackedActionInput = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, cwd: TrimmedNonEmptyStringSchema, + projectId: Schema.optional(ProjectId), action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), @@ -144,12 +152,14 @@ export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; export const GitPullRequestRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + projectId: Schema.optional(ProjectId), reference: GitPullRequestReference, }); export type GitPullRequestRefInput = typeof GitPullRequestRefInput.Type; export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + projectId: Schema.optional(ProjectId), reference: GitPullRequestReference, mode: GitPreparePullRequestThreadMode, threadId: Schema.optional(ThreadId), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..646dc16c511 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -28,6 +28,10 @@ import type { ProjectReadFileResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectDetails, + ProjectDetailsInput, + ProjectSettings, + ProjectUpdateSettingsInput, ProjectWriteFileInput, ProjectWriteFileResult, } from "./project.ts"; @@ -1192,6 +1196,8 @@ export interface EnvironmentApi { projects: { listEntries: (input: ProjectListEntriesInput) => Promise; readFile: (input: ProjectReadFileInput) => Promise; + getDetails: (input: ProjectDetailsInput) => Promise; + updateSettings: (input: ProjectUpdateSettingsInput) => Promise; searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 47fcfccb954..5fd0cd5c5ed 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -145,6 +145,7 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ "gpt-5.6-terra", ]; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; +export const DEFAULT_CODEX_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, @@ -154,6 +155,12 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> +> = { + [CODEX_DRIVER_KIND]: [{ id: "reasoningEffort", value: DEFAULT_CODEX_REASONING_EFFORT }], +}; + /** Per-provider text generation model defaults. */ export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial< Record diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ea9d5a90e7c..e54c61f70d4 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -2,11 +2,36 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; import { + ProjectActionEnvironment, + ProjectDetails, ProjectReadFileError, ProjectSearchEntriesError, ProjectWriteFileError, } from "./project.ts"; +const decodeProjectDetails = Schema.decodeUnknownSync(ProjectDetails); +const decodeProjectActionEnvironment = Schema.decodeUnknownSync(ProjectActionEnvironment); + +const baseProjectDetails = { + id: "project-1", + title: "Project", + workspaceRoot: "/repo/project", + repositoryIdentity: null, + settings: { + remoteOverride: null, + }, + detected: { + gitRoot: null, + branch: null, + remotes: [], + primaryRemote: null, + }, + effective: { + title: "Project", + remote: null, + }, +}; + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -65,3 +90,37 @@ describe("project RPC errors", () => { expect(writeError.failure).toBeUndefined(); }); }); + +describe("ProjectDetails", () => { + it("decodes legacy responses without model selection and scripts", () => { + const decoded = decodeProjectDetails(baseProjectDetails); + + expect(decoded.defaultModelSelection).toBeNull(); + expect(decoded.scripts).toEqual([]); + expect(decoded.settings.automaticGitFetchInterval).toBeNull(); + expect(decoded.settings.actionEnvironment).toEqual({}); + expect(decoded.settings.disabledProviderInstanceIds).toEqual([]); + }); + + it("rejects action environment keys reserved for T3Code runtime variables", () => { + expect(() => + decodeProjectActionEnvironment({ + T3CODE_PROJECT_ROOT: "/repo/elsewhere", + }), + ).toThrow(/reserved/); + + expect(() => + decodeProjectActionEnvironment({ + T3CODE_CUSTOM: "1", + }), + ).toThrow(/reserved/); + }); + + it("rejects object prototype keys in action environments", () => { + for (const key of ["__proto__", "constructor", "prototype"]) { + expect(() => decodeProjectActionEnvironment(JSON.parse(`{"${key}":"unsafe"}`))).toThrow( + /reserved/, + ); + } + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d59b9770ad3..5e109dda4d7 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,5 +1,10 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { RepositoryIdentity } from "./environment.ts"; +import { ModelSelection, ProjectScript } from "./orchestration.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; +import { SourceControlProviderInfo, SourceControlProviderKind } from "./sourceControl.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; @@ -223,3 +228,118 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass + key.startsWith("T3CODE_") ? "T3CODE_* environment variables are reserved." : true, + ), + ) + .check( + Schema.makeFilter((key) => + key === "__proto__" || key === "constructor" || key === "prototype" + ? "This environment variable name is reserved." + : true, + ), + ); +const ProjectActionEnvironmentValue = Schema.String.check(Schema.isMaxLength(8_192)); +const ProjectAutomaticGitFetchIntervalMs = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)); + +export const ProjectActionEnvironment = Schema.Record( + ProjectActionEnvironmentKey, + ProjectActionEnvironmentValue, +).check(Schema.isMaxProperties(128)); +export type ProjectActionEnvironment = typeof ProjectActionEnvironment.Type; + +export const ProjectSettings = Schema.Struct({ + remoteOverride: Schema.NullOr(ProjectRemoteOverride).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + automaticGitFetchInterval: Schema.NullOr(ProjectAutomaticGitFetchIntervalMs).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + actionEnvironment: ProjectActionEnvironment.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + disabledProviderInstanceIds: Schema.Array(ProviderInstanceId).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), +}); +export type ProjectSettings = typeof ProjectSettings.Type; + +export const ProjectSettingsPatch = Schema.Struct({ + remoteOverride: Schema.optionalKey(Schema.NullOr(ProjectRemoteOverride)), + automaticGitFetchInterval: Schema.optionalKey(Schema.NullOr(ProjectAutomaticGitFetchIntervalMs)), + actionEnvironment: Schema.optionalKey(ProjectActionEnvironment), + disabledProviderInstanceIds: Schema.optionalKey(Schema.Array(ProviderInstanceId)), +}); +export type ProjectSettingsPatch = typeof ProjectSettingsPatch.Type; + +export const ProjectDetailsInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ProjectDetailsInput = typeof ProjectDetailsInput.Type; + +export const ProjectUpdateSettingsInput = Schema.Struct({ + projectId: ProjectId, + patch: ProjectSettingsPatch, +}); +export type ProjectUpdateSettingsInput = typeof ProjectUpdateSettingsInput.Type; + +export const ProjectDetectedRemote = Schema.Struct({ + name: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + pushUrl: Schema.optional(TrimmedNonEmptyString), + provider: Schema.NullOr(SourceControlProviderInfo), +}); +export type ProjectDetectedRemote = typeof ProjectDetectedRemote.Type; + +export const ProjectEffectiveRemote = Schema.Struct({ + source: Schema.Literals(["override", "detected"]), + provider: SourceControlProviderKind, + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + webUrl: Schema.optional(TrimmedNonEmptyString), + providerInfo: Schema.NullOr(SourceControlProviderInfo), +}); +export type ProjectEffectiveRemote = typeof ProjectEffectiveRemote.Type; + +export const ProjectDetails = Schema.Struct({ + id: ProjectId, + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.NullOr(RepositoryIdentity), + defaultModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + scripts: Schema.Array(ProjectScript).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + settings: ProjectSettings, + detected: Schema.Struct({ + gitRoot: Schema.NullOr(TrimmedNonEmptyString), + branch: Schema.NullOr(TrimmedNonEmptyString), + remotes: Schema.Array(ProjectDetectedRemote), + primaryRemote: Schema.NullOr(ProjectDetectedRemote), + }), + effective: Schema.Struct({ + title: TrimmedNonEmptyString, + remote: Schema.NullOr(ProjectEffectiveRemote), + }), +}); +export type ProjectDetails = typeof ProjectDetails.Type; + +export class ProjectDetailsError extends Schema.TaggedErrorClass()( + "ProjectDetailsError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..4964f6f5566 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -74,6 +74,11 @@ import { ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectDetails, + ProjectDetailsError, + ProjectDetailsInput, + ProjectSettings, + ProjectUpdateSettingsInput, ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, @@ -154,6 +159,8 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", + projectsGetDetails: "projects.getDetails", + projectsUpdateSettings: "projects.updateSettings", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -389,6 +396,18 @@ export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { error: Schema.Union([ProjectReadFileError, EnvironmentAuthorizationError]), }); +export const WsProjectsGetDetailsRpc = Rpc.make(WS_METHODS.projectsGetDetails, { + payload: ProjectDetailsInput, + success: ProjectDetails, + error: Schema.Union([ProjectDetailsError, EnvironmentAuthorizationError]), +}); + +export const WsProjectsUpdateSettingsRpc = Rpc.make(WS_METHODS.projectsUpdateSettings, { + payload: ProjectUpdateSettingsInput, + success: ProjectSettings, + error: Schema.Union([ProjectDetailsError, EnvironmentAuthorizationError]), +}); + export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, @@ -720,6 +739,8 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, + WsProjectsGetDetailsRpc, + WsProjectsUpdateSettingsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..d15b9ae2332 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,9 +2,10 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ProjectId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; +import { ProjectSettings } from "./project.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -442,6 +443,9 @@ export const ServerSettings = Schema.Struct({ providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + projectSettings: Schema.Record(ProjectId, ProjectSettings).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -565,6 +569,7 @@ export const ServerSettingsPatch = Schema.Struct({ // patches risk leaving driver-specific config in a half-merged state. // The web UI sends a fully-formed map every time it edits this field. providerInstances: Schema.optionalKey(Schema.Record(ProviderInstanceId, ProviderInstanceConfig)), + projectSettings: Schema.optionalKey(Schema.Record(ProjectId, ProjectSettings)), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bdc0c0cc8ef..1404a54ba11 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,7 +1,9 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, + defaultInstanceIdForDriver, type ModelCapabilities, type ModelSelection, ProviderDriverKind, @@ -331,6 +333,17 @@ export function createModelSelection( return selections.length > 0 ? { ...base, options: selections } : base; } +export function createDefaultModelSelection( + provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, +): ModelSelection { + const model = DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + return createModelSelection( + defaultInstanceIdForDriver(provider), + model, + DEFAULT_MODEL_OPTIONS_BY_PROVIDER[provider], + ); +} + /** * Returns the effort value if it is a prompt-injected value according to * any select descriptor in the given capabilities, or null otherwise. diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3cb..f4f1fd2b31e 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -8,6 +8,14 @@ interface ProjectScriptRuntimeEnvInput { extraEnv?: Record; } +const T3CODE_RUNTIME_ENV_PREFIX = "T3CODE_"; + +function userProjectScriptEnv(input: Record): Record { + return Object.fromEntries( + Object.entries(input).filter(([key]) => !key.startsWith(T3CODE_RUNTIME_ENV_PREFIX)), + ); +} + export function projectScriptCwd(input: { project: { cwd: string; @@ -27,7 +35,7 @@ export function projectScriptRuntimeEnv( env.T3CODE_WORKTREE_PATH = input.worktreePath; } if (input.extraEnv) { - return { ...env, ...input.extraEnv }; + return { ...userProjectScriptEnv(input.extraEnv), ...env }; } return env; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 5bec7d386b6..51767ae9a47 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; @@ -194,4 +195,81 @@ describe("serverSettings helpers", () => { config: { homePath: "~/.codex" }, }); }); + + it("replaces projectSettings maps so removed action environment keys stay removed", () => { + const projectId = ProjectId.make("project-1"); + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectSettings: { + [projectId]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: { + API_BASE_URL: "https://api.example.test", + DEBUG: "1", + }, + disabledProviderInstanceIds: [], + }, + }, + }; + + expect( + applyServerSettingsPatch(current, { + projectSettings: { + [projectId]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: { + API_BASE_URL: "https://api.example.test", + }, + disabledProviderInstanceIds: [], + }, + }, + }).projectSettings[projectId]?.actionEnvironment, + ).toEqual({ + API_BASE_URL: "https://api.example.test", + }); + + expect( + applyServerSettingsPatch(current, { + projectSettings: { + [projectId]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }, + }, + }).projectSettings[projectId]?.actionEnvironment, + ).toEqual({}); + }); + + it("keeps projectSettings entries omitted from a patch", () => { + const projectA = ProjectId.make("project-a"); + const projectB = ProjectId.make("project-b"); + const entryA = { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: { DEBUG: "1" }, + disabledProviderInstanceIds: [], + }; + const entryB = { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: { API_BASE_URL: "https://api.example.test" }, + disabledProviderInstanceIds: [], + }; + const current = { + ...DEFAULT_SERVER_SETTINGS, + projectSettings: { [projectA]: entryA }, + }; + + // A patch built from a snapshot that only knows about project B must not + // clobber project A's persisted settings. + const next = applyServerSettingsPatch(current, { + projectSettings: { [projectB]: entryB }, + }); + expect(next.projectSettings[projectA]).toEqual(entryA); + expect(next.projectSettings[projectB]).toEqual(entryB); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b..1253bbc6594 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,11 +1,15 @@ -import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; +import { + ServerSettings as ServerSettingsSchema, + type ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { deepMerge } from "./Struct.ts"; import { fromLenientJson } from "./schemaJson.ts"; import { createModelSelection } from "./model.ts"; -const ServerSettingsJson = fromLenientJson(ServerSettings); +const ServerSettingsJson = fromLenientJson(ServerSettingsSchema); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); export interface PersistedServerObservabilitySettings { @@ -83,6 +87,14 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + // Each patched project entry is taken as fully-formed (same replace + // semantics as providerInstances), but entries omitted from the patch + // must survive: patches are built from possibly-stale snapshots, and a + // writer editing project A must not clobber a concurrent write to + // project B. + ...(patch.projectSettings !== undefined + ? { projectSettings: { ...current.projectSettings, ...patch.projectSettings } } + : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; if (!selectionPatch) {