From 7f57a645330903ef08f33ab5ea3ed0de02662414 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:00:09 +0530 Subject: [PATCH 01/23] Add per-project settings --- apps/server/src/git/GitManager.test.ts | 46 +- apps/server/src/git/GitManager.ts | 89 +- .../Layers/ProviderCommandReactor.test.ts | 3 +- .../project/ProjectSetupScriptRunner.test.ts | 25 +- .../src/project/ProjectSetupScriptRunner.ts | 16 +- .../provider/Layers/ProviderRegistry.test.ts | 47 +- apps/server/src/server.test.ts | 10 +- apps/server/src/serverRuntimeStartup.test.ts | 11 +- apps/server/src/serverRuntimeStartup.ts | 9 +- apps/server/src/serverSettings.test.ts | 25 + apps/server/src/serverSettings.ts | 122 +- .../src/sourceControl/RemoteOverride.ts | 77 + .../SourceControlDiscovery.test.ts | 6 + .../SourceControlProviderRegistry.test.ts | 70 +- .../SourceControlProviderRegistry.ts | 167 +- apps/server/src/vcs/GitVcsDriver.ts | 2 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 40 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 268 ++-- apps/server/src/ws.ts | 326 +++- apps/web/src/components/ChatView.tsx | 102 +- apps/web/src/components/GitActionsControl.tsx | 29 +- .../src/components/ProjectScriptsControl.tsx | 119 +- apps/web/src/components/Sidebar.tsx | 110 +- .../src/components/ThreadStatusIndicators.tsx | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 56 +- apps/web/src/composerDraftStore.test.ts | 18 + apps/web/src/composerDraftStore.ts | 7 +- apps/web/src/hooks/useHandleNewThread.ts | 13 +- .../src/lib/projectScriptKeybindings.test.ts | 133 +- apps/web/src/lib/projectScriptKeybindings.ts | 92 +- apps/web/src/projectScripts.test.ts | 22 +- apps/web/src/projectScripts.ts | 2 +- apps/web/src/providerInstances.test.ts | 35 + apps/web/src/providerInstances.ts | 26 + apps/web/src/routeTree.gen.ts | 21 + .../projects.$environmentId.$projectId.tsx | 1412 +++++++++++++++++ apps/web/src/state/sourceControlActions.ts | 16 +- .../src/state/projectCommands.ts | 11 + .../client-runtime/src/state/vcsStatus.ts | 3 +- packages/contracts/src/git.ts | 9 +- packages/contracts/src/ipc.ts | 6 + packages/contracts/src/model.ts | 9 +- packages/contracts/src/project.test.ts | 51 + packages/contracts/src/project.ts | 115 +- packages/contracts/src/rpc.ts | 21 + packages/contracts/src/settings.ts | 7 +- packages/shared/src/model.ts | 13 + packages/shared/src/projectScripts.ts | 10 +- packages/shared/src/serverSettings.test.ts | 49 + packages/shared/src/serverSettings.ts | 9 +- 50 files changed, 3459 insertions(+), 428 deletions(-) create mode 100644 apps/server/src/sourceControl/RemoteOverride.ts create mode 100644 apps/web/src/routes/projects.$environmentId.$projectId.tsx diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..e21ab2e52af 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -16,6 +16,7 @@ import type { GitActionProgressEvent, GitPreparePullRequestThreadInput, ModelSelection, + SourceControlProviderInfo, ThreadId, } from "@t3tools/contracts"; @@ -636,6 +637,8 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; + sourceControlProviderContext?: SourceControlProviderRegistry.SourceControlProviderHandle["context"]; + sourceControlProviderContextSource?: SourceControlProviderRegistry.SourceControlProviderHandle["contextSource"]; textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; }) { @@ -658,7 +661,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([]), }), @@ -693,6 +701,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* () { @@ -736,6 +756,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 f1fb03e7e45..63d46a12744 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -95,6 +95,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"; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; @@ -525,7 +526,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( @@ -726,7 +728,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, @@ -740,14 +771,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 { @@ -765,13 +798,13 @@ 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); 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))); @@ -781,7 +814,7 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* findLatestPr(cwd, { + ? yield* findLatestPr(input, { branch: details.branch, upstreamRef: details.upstreamRef, }).pipe( @@ -809,17 +842,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" @@ -828,7 +873,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* ( @@ -961,14 +1013,15 @@ export const make = Effect.gen(function* () { }); const findLatestPr = Effect.fn("findLatestPr")(function* ( - cwd: string, + input: VcsStatusInput, details: { branch: string; upstreamRef: string | null }, ) { + const cwd = input.cwd; const headContext = yield* resolveBranchHeadContext(cwd, details); 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", @@ -1409,13 +1462,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); } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..1901654b064 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 Exit from "effect/Exit"; @@ -250,7 +251,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/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index fdf95df0b99..ff8e6ef6b25 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -6,6 +6,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"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; @@ -62,9 +63,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)), ); @@ -138,6 +141,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", }, @@ -147,7 +151,26 @@ 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: [], + }, + }, + }, + ), + ), + ); }, ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf48..2e9edeee632 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 + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const actionEnvironment = settings.projectSettings[project.id]?.actionEnvironment ?? {}; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, + extraEnv: actionEnvironment, }); yield* terminalManager diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..90e8b97fb32 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"; @@ -278,19 +279,47 @@ 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; - }), + 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/server.test.ts b/apps/server/src/server.test.ts index 26528c84d34..701f53aecc9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -27,6 +27,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + type VcsStatusInput, WS_METHODS, WsRpcGroup, EditorId, @@ -554,6 +555,13 @@ const buildAppUnderTest = (options?: { ready: Effect.void, getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateProjectSettings: () => + Effect.succeed({ + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }), streamChanges: Stream.empty, ...options?.layers?.serverSettings, }), @@ -6095,7 +6103,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 e331f0cd4d6..8c0958782a5 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 504d99e18de..13cd5b945e7 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, @@ -202,6 +203,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..bdab5f17bb6 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, @@ -108,26 +117,34 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS return { ...settings, providerInstances }; } -export class ServerSettingsService extends Context.Service< - ServerSettingsService, - { - /** Start the settings runtime and attach file watching. */ - readonly start: Effect.Effect; +export interface ServerSettingsShape { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; - /** Await settings runtime readiness. */ - readonly ready: Effect.Effect; + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; - /** Read the current settings. */ - readonly getSettings: Effect.Effect; + /** Read the current settings. */ + readonly getSettings: Effect.Effect; - /** Patch settings and persist. Returns the new full settings object. */ - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => Effect.Effect; + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; - /** Stream of settings change events. */ - readonly streamChanges: Stream.Stream; - } + /** 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; +} + +export class ServerSettingsService extends Context.Service< + ServerSettingsService, + ServerSettingsShape >()("t3/serverSettings/ServerSettingsService") { /** @deprecated Import and use `layerTest` from this module. */ static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); @@ -144,16 +161,35 @@ 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) => - Ref.get(currentSettingsRef).pipe( - Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), - Effect.flatMap(normalizeServerSettings), - Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), + 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 +592,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 +616,18 @@ 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); - }), + 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/RemoteOverride.ts b/apps/server/src/sourceControl/RemoteOverride.ts new file mode 100644 index 00000000000..b5476d9c47a --- /dev/null +++ b/apps/server/src/sourceControl/RemoteOverride.ts @@ -0,0 +1,77 @@ +import type { + ProjectRemoteOverride, + SourceControlProviderInfo, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +import * as SourceControlProvider from "./SourceControlProvider.ts"; + +export function parseRemoteHost(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.startsWith("git@")) { + const hostWithPath = trimmed.slice("git@".length); + const separatorIndex = hostWithPath.search(/[:/]/); + return separatorIndex > 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 = override.webUrl + ? 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..23fbcec178f 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -5,9 +5,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { VcsRepositoryDetectionError } from "@t3tools/contracts"; +import { ProjectId, 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 +17,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 +43,20 @@ function makeRegistry(input: { }>; readonly process?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; + readonly settings?: Parameters[0]; }) { 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) => ({ @@ -92,9 +107,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()), + }), + ServerSettings.layerTest(input.settings), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), ), ), ); @@ -112,6 +131,49 @@ 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("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({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..0638a40d30b 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -4,7 +4,9 @@ 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 { + type ProjectId, SourceControlProviderError, type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; @@ -15,18 +17,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,28 +45,31 @@ export interface SourceControlProviderRegistration { export interface SourceControlProviderHandle { readonly provider: SourceControlProvider.SourceControlProvider["Service"]; readonly context: SourceControlProvider.SourceControlProviderContext | null; + readonly contextSource: "override" | "detected" | null; +} + +export interface SourceControlProviderRegistryShape { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly resolveHandle: ( + input: SourceControlProviderResolveInput, + ) => Effect.Effect; + readonly resolve: ( + input: SourceControlProviderResolveInput, + ) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly discover: Effect.Effect>; } export class SourceControlProviderRegistry extends Context.Service< SourceControlProviderRegistry, - { - readonly get: ( - kind: SourceControlProviderKind, - ) => Effect.Effect< - SourceControlProvider.SourceControlProvider["Service"], - SourceControlProviderError - >; - readonly resolveHandle: (input: { - readonly cwd: string; - }) => Effect.Effect; - readonly resolve: (input: { - readonly cwd: string; - }) => Effect.Effect< - SourceControlProvider.SourceControlProvider["Service"], - SourceControlProviderError - >; - readonly discover: Effect.Effect>; - } + SourceControlProviderRegistryShape >()("t3/sourceControl/SourceControlProviderRegistry") {} function unsupportedProvider( @@ -123,6 +134,27 @@ function unsupportedProvider( }); } +function providerDetectionError(operation: string, cwd: string, cause: unknown) { + return new SourceControlProviderError({ + provider: "unknown", + operation, + cwd, + detail: "Failed to detect source control provider.", + cause, + }); +} + +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; @@ -196,6 +228,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,59 +242,84 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( - function* (cwd: string) { - const handle = yield* vcsRegistry.resolve({ cwd }).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "unknown", - operation: "detectProvider", - cwd, - detail: "Failed to detect source control provider.", - cause: error, - }), - ), - ); - const remotes = yield* handle.driver.listRemotes(cwd).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "unknown", - operation: "detectProvider", - cwd, - detail: "Failed to detect source control provider.", - cause: error, - }), - ), - ); + function* (cacheKey: string) { + const input = providerDetectionInputFromCacheKey(cacheKey); + const { cwd, projectId } = input; + + if (projectId) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)), + ); + const override = settings.projectSettings[projectId]?.remoteOverride ?? null; + 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) => providerDetectionError("detectProvider", cwd, error))); + 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 || repository.rootPath === cwd + ? Effect.succeed(project) + : projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(repository.rootPath), + ), + Effect.catch(() => Effect.succeed(Option.none())), + ); + if (!projectId && Option.isSome(projectOption)) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)), + ); + const override = settings.projectSettings[projectOption.value.id]?.remoteOverride ?? null; + 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) => providerDetectionError("detectProvider", cwd, error))); 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), }); - const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => - Cache.get(providerContextCache, input.cwd).pipe( - Effect.map((context) => { + const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + 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 +330,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/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/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 032e48e4612..88e0365b309 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; @@ -125,6 +129,36 @@ 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("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 c238154f58c..6e369d0d301 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -27,9 +27,57 @@ 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 interface VcsStatusBroadcasterShape { + 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; +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + VcsStatusBroadcasterShape +>()("t3/vcs/VcsStatusBroadcaster") {} + function boundedDiagnosticValue(value: string): string { return value.slice(0, MAX_FAILURE_DIAGNOSTIC_VALUE_LENGTH); } @@ -113,30 +161,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 +175,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 +185,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 +205,31 @@ 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 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 +238,7 @@ export const make = Effect.gen(function* () { if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + key, event: { _tag: "localUpdated", local, @@ -228,15 +251,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 +268,7 @@ export const make = Effect.gen(function* () { if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + key, event: { _tag: "remoteUpdated", remote, @@ -258,7 +281,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 +295,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 +310,7 @@ export const make = Effect.gen(function* () { if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + key, event: { _tag: "snapshot", local, @@ -300,89 +323,102 @@ export const make = Effect.gen(function* () { }); const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( - cwd: string, + input: VcsStatusInput, + ) { + const local = yield* workflow.localStatus(input); + return yield* updateCachedLocalStatus(statusCacheKey(input), local); + }); + + const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( + input: VcsStatusInput, ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + 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( + const getStatus: VcsStatusBroadcasterShape["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 }); }, ); - const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( + const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); + return yield* refreshLocalStatusForInput({ cwd }); }); 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); + return yield* updateCachedRemoteStatus(statusCacheKey(input), remote, { publish: true }); }); - const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( + const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { - const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { - concurrency: "unbounded", - discard: true, - }); + )(function* (input) { + const normalizedInput = yield* normalizeStatusInput(refreshInputToStatusInput(input)); + yield* Effect.all( + [ + workflow.invalidateLocalStatus(normalizedInput.cwd), + workflow.invalidateRemoteStatus(normalizedInput.cwd), + ], + { concurrency: "unbounded", discard: true }, + ); const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + [workflow.localStatus(normalizedInput), workflow.remoteStatus(normalizedInput)], { concurrency: "unbounded" }, ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + return yield* updateCachedStatus(statusCacheKey(normalizedInput), local, remote, { + publish: true, + }); }); 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* () { @@ -395,7 +431,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)) { @@ -415,7 +451,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), @@ -441,29 +478,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, }); @@ -474,17 +511,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, }); @@ -492,7 +529,7 @@ export const make = Effect.gen(function* () { } const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); + nextPollers.delete(key); return [existing.fiber, nextPollers] as const; }); @@ -501,22 +538,23 @@ export const make = Effect.gen(function* () { } }); - const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => + const streamStatus: VcsStatusBroadcasterShape["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({ @@ -525,7 +563,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 9020e99f670..5f8e5777007 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -39,7 +39,13 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectDetailsError, ProjectSearchEntriesError, + type ProjectDetectedRemote, + type ProjectEffectiveRemote, + type ProjectId, + type ProjectRemoteOverride, + type ProjectSettingsPatch, ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, @@ -57,6 +63,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"; @@ -104,6 +111,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"; @@ -301,6 +309,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], @@ -345,6 +355,59 @@ 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 { + const providerInfo = providerInfoFromOverride(override); + 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) { + return ( + remotes.find((remote) => remote.name === "origin") ?? + remotes.find((remote) => remote.provider !== null && remote.provider.kind !== "unknown") ?? + remotes[0] ?? + null + ); +} + +const isProjectDetailsError = Schema.is(ProjectDetailsError); + function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -397,6 +460,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; @@ -881,19 +945,62 @@ const makeWsRpcLayer = ( ); }); + 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.catch(() => Effect.succeed(Option.none()))); + 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 = - normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + 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) @@ -941,6 +1048,120 @@ 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.getSettings.pipe( + Effect.map( + (settings) => + settings.projectSettings[projectId] ?? ServerSettings.emptyProjectSettings, + ), + ); + + 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( @@ -1322,6 +1543,81 @@ 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); + + 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, @@ -1454,20 +1750,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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..ae69a7d6608 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,6 +33,7 @@ import { } from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, + createDefaultModelSelection, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; @@ -129,6 +130,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"; @@ -141,7 +143,7 @@ import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; 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 { commandForProjectScript, @@ -183,11 +185,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; -import { - primaryServerAvailableEditorsAtom, - primaryServerKeybindingsAtom, - serverEnvironment, -} from "../state/server"; +import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; @@ -254,6 +252,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 = {}; const PreviewPanel = lazy(() => @@ -333,6 +332,17 @@ function formatOutgoingPrompt(params: { const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function projectScriptPreviewFields( + input: Pick, +): Pick { + return input.previewUrl + ? { + previewUrl: input.previewUrl, + autoOpenPreview: input.autoOpenPreview, + } + : {}; +} + type ChatViewProps = | { environmentId: EnvironmentId; @@ -994,9 +1004,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"); @@ -1229,10 +1236,7 @@ function ChatViewContent(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + fallbackDraftProject?.defaultModelSelection ?? createDefaultModelSelection(), ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], @@ -1633,6 +1637,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 @@ -2117,7 +2125,7 @@ function ChatViewContent(props: ChatViewProps) { ? null : vcsEnvironment.status({ environmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, ...(activeProject ? { projectId: activeProject.id } : {}) }, }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -2147,6 +2155,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. @@ -2476,7 +2489,10 @@ function ChatViewContent(props: ChatViewProps) { cwd: activeProject.workspaceRoot, }, worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), + extraEnv: { + ...activeProjectActionEnvironment, + ...options?.env, + }, }); const targetTerminalId = shouldCreateNewTerminal ? nextTerminalId(activeKnownTerminalIds) @@ -2531,6 +2547,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}".`, + ); + } } }, [ @@ -2538,6 +2572,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread, activeThreadId, activeThreadRef, + activeProjectActionEnvironment, gitCwd, setTerminalOpen, setThreadError, @@ -2546,6 +2581,7 @@ function ChatViewContent(props: ChatViewProps) { setLastInvokedScriptByProjectId, environmentId, openTerminal, + openPreview, activeKnownTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, @@ -2576,23 +2612,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> => { @@ -2609,6 +2649,7 @@ function ChatViewContent(props: ChatViewProps) { command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, + ...projectScriptPreviewFields(input), }; const nextScripts = input.runOnWorktreeCreate ? [ @@ -2644,11 +2685,12 @@ function ChatViewContent(props: ChatViewProps) { } const updatedScript: ProjectScript = { - ...existingScript, + id: existingScript.id, name: input.name, command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, + ...projectScriptPreviewFields(input), }; const nextScripts = activeProject.scripts.map((script) => script.id === scriptId @@ -5181,7 +5223,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} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c9816719452..c779d1dd4c1 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, @@ -139,18 +140,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; @@ -368,6 +370,7 @@ interface PublishRepositoryDialogProps { readonly onOpenChange: (open: boolean) => void; readonly environmentId: ScopedThreadRef["environmentId"] | null; readonly gitCwd: string; + readonly projectId: ProjectId | null; } function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { @@ -397,8 +400,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(() => { @@ -993,6 +997,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(""); @@ -1003,8 +1008,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; @@ -1081,7 +1086,7 @@ export default function GitActionsControl({ activeEnvironmentId !== null && gitCwd !== null ? vcsEnvironment.status({ environmentId: activeEnvironmentId, - input: { cwd: gitCwd }, + input: { cwd: gitCwd, ...(activeProjectId ? { projectId: activeProjectId } : {}) }, }) : null, ); @@ -1190,7 +1195,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 = () => { @@ -1209,7 +1214,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(); @@ -1727,7 +1732,12 @@ export default function GitActionsControl({ { if (open) { - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + requestVcsStatusRefresh( + refreshVcsStatus, + activeEnvironmentId, + gitCwd, + activeProjectId, + ); } }} > @@ -1989,6 +1999,7 @@ export default function GitActionsControl({ onOpenChange={setIsPublishDialogOpen} environmentId={activeEnvironmentId} gitCwd={gitCwd} + projectId={activeProjectId} /> ; interface ProjectScriptsControlProps { scripts: ReadonlyArray; keybindings: ResolvedKeybindingsConfig; + variant?: "toolbar" | "settings"; preferredScriptId?: string | null; - onRunScript: (script: ProjectScript) => void; + onRunScript?: (script: ProjectScript) => void; onAddScript: (input: NewProjectScriptInput) => Promise; onUpdateScript: ( scriptId: string, @@ -114,6 +115,7 @@ interface ProjectScriptsControlProps { export default function ProjectScriptsControl({ scripts, keybindings, + variant = "toolbar", preferredScriptId = null, onRunScript, onAddScript, @@ -247,9 +249,76 @@ export default function ProjectScriptsControl({ void onDeleteScript(editingScriptId); }, [editingScriptId, onDeleteScript]); - return ( - <> - {primaryScript ? ( + const trigger = (() => { + if (variant === "settings") { + return ( +
+ {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} +
+
+ +
+ ); + })} +
+ ) : ( +
+ No project actions configured. +
+ )} +
+ +
+
+ ); + } + + if (primaryScript) { + return ( onRunScript(primaryScript)} + onClick={() => onRunScript?.(primaryScript)} /> } > @@ -286,7 +355,7 @@ export default function ProjectScriptsControl({ onRunScript(script)} + onClick={() => onRunScript?.(script)} > @@ -327,21 +396,29 @@ export default function ProjectScriptsControl({
- ) : ( - - - } - > - - - Add action - - - Add action - - )} + ); + } + + return ( + + + } + > + + + Add action + + + Add action + + ); + })(); + + return ( + <> + {trigger} { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..c573823b37c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -427,7 +427,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, ); @@ -1207,10 +1207,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); - const [projectRenameTarget, setProjectRenameTarget] = useState( - null, - ); - const [projectRenameTitle, setProjectRenameTitle] = useState(""); const [projectGroupingTarget, setProjectGroupingTarget] = useState(null); const [projectGroupingSelection, setProjectGroupingSelection] = useState< @@ -1375,14 +1371,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, ], @@ -1391,10 +1395,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], @@ -1596,7 +1615,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; @@ -1606,6 +1625,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; @@ -1629,7 +1654,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; @@ -1663,6 +1688,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"), @@ -1686,10 +1712,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, - openProjectGroupingDialog, openProjectRenameDialog, + openProjectGroupingDialog, project.groupedProjectCount, project.memberProjects, + router, suppressProjectClickForContextMenuRef, ], ); @@ -2200,10 +2227,39 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <>
+ - {!projectExpanded && projectStatus ? ( - - - } - > - - - - - - {projectStatus.label} - - ) : ( - - )} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 3e85920d190..6ddc056b828 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -202,7 +202,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar thread.branch != 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 5f9aec837ca..917d6c605e0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -109,6 +109,7 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, + resolveProjectProviderInstancePolicy, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -657,13 +658,23 @@ 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 hasProjectProviderInstances = providerInstanceEntries.length > 0; const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.session?.providerInstanceId ?? @@ -714,9 +725,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]; for (const candidate of candidates) { if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, - ); + const match = providerInstanceEntries.find((entry) => entry.instanceId === candidate); if (match) { // When locked to a specific driver kind, ignore persisted instance // ids from a different kind or continuation group. @@ -730,22 +739,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return match.instanceId; } } - if (explicitSelectedInstanceId) { - return ProviderInstanceId.make(explicitSelectedInstanceId); - } const byKind = providerInstanceEntries.find( (entry) => - entry.enabled && entry.driverKind === selectedProvider && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); return ( - anyEnabled?.instanceId ?? + projectProviderPolicy.firstAllowedProvider?.instanceId ?? providerInstanceEntries[0]?.instanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? ProviderInstanceId.make("codex") ); }, [ @@ -753,10 +755,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, + projectProviderPolicy.firstAllowedProvider?.instanceId, selectedProvider, ]); @@ -923,6 +925,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, ], ); + const hasSendableContent = composerSendState.hasSendableContent && hasProjectProviderInstances; // ------------------------------------------------------------------ // Derived: composer trigger / menu @@ -1056,11 +1059,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (showPlanFollowUpPrompt) { return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; } - return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; + return `idle:${hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; }, [ activePendingIsResponding, activePendingProgress, - composerSendState.hasSendableContent, + hasSendableContent, isConnecting, isPreparingWorktree, isSendBusy, @@ -1135,7 +1138,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; + phase === "running" || isSendBusy || isConnecting || !hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1677,11 +1680,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } - return showPlanFollowUpPrompt || composerSendState.hasSendableContent; + return ( + (showPlanFollowUpPrompt || composerSendState.hasSendableContent) && + hasProjectProviderInstances + ); }, [ activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, + hasProjectProviderInstances, isConnecting, isMobileViewport, isSendBusy, @@ -1691,12 +1698,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (!hasProjectProviderInstances && !activePendingProgress) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [ + activePendingProgress, + blurMobileComposerAfterSend, + hasProjectProviderInstances, + onSend, + shouldBlurMobileComposerOnSubmit, + ], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2481,6 +2498,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptionsByInstance={modelOptionsByInstance} terminalOpen={terminalOpen} open={isComposerModelPickerOpen} + disabled={!hasProjectProviderInstances} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: composerProviderState.modelPickerIconClassName, @@ -2549,7 +2567,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} + hasSendableContent={hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..82c3e3d6377 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"), { + preserveExistingOptions: false, + }); + + 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/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..7495169c1c2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -406,6 +406,9 @@ interface ComposerDraftStoreState { setModelSelection: ( threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, + options?: { + preserveExistingOptions?: boolean; + }, ) => void; /** Replace the model options for one or more providers in the draft. */ setModelOptions: ( @@ -2594,7 +2597,7 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - setModelSelection: (threadRef, modelSelection) => { + setModelSelection: (threadRef, modelSelection, options) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { return; @@ -2609,7 +2612,7 @@ const composerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; if (normalized) { const current = nextMap[normalized.instanceId]; - if (normalized.options !== undefined) { + if (normalized.options !== undefined || options?.preserveExistingOptions === false) { // Explicit options provided → use them nextMap[normalized.instanceId] = normalized as ModelSelection; } else { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 1b1c07b31c9..e86d257733f 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -6,8 +6,10 @@ import { import { DEFAULT_RUNTIME_MODE, DEFAULT_SERVER_SETTINGS, + type ModelSelection, type ScopedProjectRef, } from "@t3tools/contracts"; +import { createDefaultModelSelection } from "@t3tools/shared/model"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -53,9 +55,9 @@ export function useNewThreadHandler() { getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, - applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, + setModelSelection, } = useComposerDraftStore.getState(); const currentRouteTarget = getCurrentRouteTarget(); const project = projects.find( @@ -83,6 +85,13 @@ export function useNewThreadHandler() { if (storedDraftThreadRef && reusableStoredDraftThread === null) { markPromotedDraftThreadByRef(storedDraftThreadRef); } + const projectDefaultModelSelection: ModelSelection = + project?.defaultModelSelection ?? createDefaultModelSelection(); + const seedNewDraftModelState = (draftId: Parameters[0]) => { + setModelSelection(draftId, projectDefaultModelSelection, { + preserveExistingOptions: false, + }); + }; const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) @@ -175,7 +184,7 @@ export function useNewThreadHandler() { }), runtimeMode: DEFAULT_RUNTIME_MODE, }); - applyStickyState(draftId); + seedNewDraftModelState(draftId); await router.navigate({ to: "/draft/$draftId", 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..6021cffb91e 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,44 @@ 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; + } + + for (const target of existingTargets) { + if (target.key !== nextRule.key) { + await input.server.removeKeybinding(target); + } + } + await input.server.upsertKeybinding(nextRule); +} diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 3597b714c77..3859a35ee93 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -60,18 +60,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 f0aeead1411..9af2ab879f9 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -56,7 +56,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/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index a5b03f56328..0a270942e42 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -7,6 +7,7 @@ import { isProviderInstancePickerVisible, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, + resolveProjectProviderInstancePolicy, } from "./providerInstances"; function provider(input: { @@ -169,6 +170,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 c9ac87ac39f..5c5629a3a6c 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -17,6 +17,7 @@ import { PROVIDER_DISPLAY_NAMES, type ProviderDriverKind, type ProviderInstanceId, + type ProjectSettings, type ServerProvider, type ServerProviderModel, type ServerSettings, @@ -231,6 +232,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/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..bc2a8e25d5c 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +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' @@ -77,6 +78,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } 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', @@ -93,6 +99,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -106,6 +113,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -122,6 +130,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -139,6 +148,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/projects/$environmentId/$projectId' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -152,6 +162,7 @@ export interface FileRouteTypes { to: | '/pair' | '/settings' + | '/projects/$environmentId/$projectId' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -167,6 +178,7 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' + | '/projects/$environmentId/$projectId' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -183,6 +195,7 @@ export interface RootRouteChildren { ChatRoute: typeof ChatRouteWithChildren PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + ProjectsEnvironmentIdProjectIdRoute: typeof ProjectsEnvironmentIdProjectIdRoute } declare module '@tanstack/react-router' { @@ -264,6 +277,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/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' @@ -323,6 +343,7 @@ const rootRouteChildren: RootRouteChildren = { ChatRoute: ChatRouteWithChildren, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + ProjectsEnvironmentIdProjectIdRoute: ProjectsEnvironmentIdProjectIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) 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..c7498e040c7 --- /dev/null +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -0,0 +1,1412 @@ +import { ArrowLeftIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"; +import { createFileRoute, redirect, useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { createDefaultModelSelection, createModelSelection } from "@t3tools/shared/model"; +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, + SourceControlProviderKind, +} from "@t3tools/contracts"; +import { DEFAULT_MODEL } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; + +import { cn } from "../lib/utils"; +import { ensureLocalApi, readLocalApi } from "../localApi"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../store"; +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 { useServerKeybindings, useServerProviders } from "../rpc/serverState"; +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { useSavedEnvironmentRuntimeStore } from "../environments/runtime"; +import ProjectScriptsControl, { + type NewProjectScriptInput, + type ProjectScriptActionResult, +} from "../components/ProjectScriptsControl"; +import { commandForProjectScript, nextProjectScriptId } from "../projectScripts"; +import { syncProjectScriptKeybinding } from "../lib/projectScriptKeybindings"; +import { useSettings } from "../hooks/useSettings"; +import { + getCustomModelOptionsByInstance, + resolveAppModelSelectionForInstance, +} from "../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveProjectProviderInstancePolicy, + sortProviderInstanceEntries, +} from "../providerInstances"; +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"; + +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 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; +} + +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[]; +} + +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); +} + +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 = useStore(useShallow(selectProjectsAcrossEnvironments)); + const threads = useStore(useShallow(selectThreadsAcrossEnvironments)); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryProviders = useServerProviders(); + const keybindings = useServerKeybindings(); + const primarySettings = useSettings(); + const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); + const updateProjectSettings = useAtomCommand(projectEnvironment.updateSettings, { + reportFailure: false, + }); + const remoteRuntimeState = useSavedEnvironmentRuntimeStore((state) => + project?.environmentId ? state.byId[project.environmentId] : null, + ); + const settings = useMemo( + () => + project?.environmentId && project.environmentId !== primaryEnvironmentId + ? { + ...primarySettings, + ...remoteRuntimeState?.serverConfig?.settings, + } + : primarySettings, + [ + primaryEnvironmentId, + primarySettings, + project?.environmentId, + remoteRuntimeState?.serverConfig?.settings, + ], + ); + const serverProviders = + project?.environmentId && project.environmentId !== primaryEnvironmentId + ? (remoteRuntimeState?.serverConfig?.providers ?? primaryProviders) + : 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 details = projectDetails.data; + const projectDraftKey = project && details ? `${project.environmentId}:${details.id}` : null; + const currentDraft = draft?.projectKey === projectDraftKey ? draft : null; + const override = details?.settings.remoteOverride ?? null; + const title = currentDraft?.title ?? details?.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 settingsCommitQueueRef = useRef>(Promise.resolve()); + const refreshProjectDetails = projectDetails.refresh; + + const commitProjectMeta = useCallback( + async (patch: { title?: string; defaultModelSelection?: ModelSelection | null }) => { + if (!project) return; + const result = await updateProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + ...patch, + }, + }); + if (result._tag === "Failure") { + showProjectSettingsError( + "Failed to update project settings", + squashAtomCommandFailure(result), + ); + return; + } + refreshProjectDetails(); + }, + [project, refreshProjectDetails, showProjectSettingsError, updateProject], + ); + + const commitProjectSettings = useCallback( + (patch: ProjectSettingsPatch) => { + const nextCommit = settingsCommitQueueRef.current + .catch(() => undefined) + .then(async () => { + if (!project) return; + const result = await updateProjectSettings({ + environmentId: project.environmentId, + input: { + projectId: project.id, + patch, + }, + }); + if (result._tag === "Failure") { + showProjectSettingsError( + "Failed to update project settings", + squashAtomCommandFailure(result), + ); + return; + } + refreshProjectDetails(); + }); + settingsCommitQueueRef.current = nextCommit.catch(() => undefined); + return nextCommit; + }, + [project, refreshProjectDetails, showProjectSettingsError, 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 (!projectDetails.data) return; + if (trimmed.length === 0) { + stageDraft({ title: projectDetails.data.title }); + showProjectSettingsError( + "Failed to update project settings", + new Error("Project name cannot be empty."), + ); + return; + } + stageDraft({ title: trimmed }); + if (trimmed !== projectDetails.data.title) { + void commitProjectMeta({ title: trimmed }); + } + }, + [commitProjectMeta, projectDetails.data, showProjectSettingsError, stageDraft], + ); + + const commitDefaultModelSelection = useCallback( + (nextSelection: ModelSelection | null) => { + stageDraft({ defaultModelSelection: nextSelection }); + if ( + !isModelSelectionEqual(nextSelection, projectDetails.data?.defaultModelSelection ?? null) + ) { + void commitProjectMeta({ defaultModelSelection: nextSelection }); + } + }, + [commitProjectMeta, projectDetails.data?.defaultModelSelection, stageDraft], + ); + + const commitAutomaticGitFetchInterval = useCallback( + (nextIntervalMs: number | null) => { + stageDraft({ automaticGitFetchInterval: nextIntervalMs }); + const currentIntervalMs = projectDetails.data?.settings.automaticGitFetchInterval ?? null; + if (nextIntervalMs === currentIntervalMs) { + return; + } + void commitProjectSettings({ automaticGitFetchInterval: nextIntervalMs }); + }, + [commitProjectSettings, projectDetails.data?.settings.automaticGitFetchInterval, stageDraft], + ); + + const commitActionEnvironment = useCallback( + (nextEnvironment: ProjectActionEnvironment) => { + stageDraft({ actionEnvironment: nextEnvironment }); + 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 reserved for T3Code runtime variables.`), + ); + return; + } + if (!isStringRecordEqual(normalized, projectDetails.data?.settings.actionEnvironment ?? {})) { + void commitProjectSettings({ actionEnvironment: normalized }); + } + }, + [ + commitProjectSettings, + projectDetails.data?.settings.actionEnvironment, + 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 }); + void commitProjectSettings({ + disabledProviderInstanceIds: nextDisabledProviderInstanceIds, + }); + }, + [ + commitProjectSettings, + 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 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, + }), + ); + if (keybindingResult._tag === "Failure") { + return keybindingResult; + } + refreshProjectDetails(); + 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 removeProject = useCallback(async () => { + if (!project || removeProjectPending) return; + setRemoveProjectPending(true); + try { + const willDeleteThreads = projectThreadCount > 0; + const message = [ + willDeleteThreads + ? `Remove project "${project.name}" and delete its ${projectThreadCount} thread${ + projectThreadCount === 1 ? "" : "s" + }?` + : `Remove project "${project.name}"?`, + `Path: ${project.cwd}`, + 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 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; + return ( + +
+
+ + +
+
Project settings
+
+ +
+ + {project && projectDetails.isPending && !projectDetails.data ? ( + + ) : ( + + {!project ? ( + + ) : projectDetails.error !== null ? ( + + ) : projectDetails.data ? ( + <> +
+
+
+

+ {projectDetails.data.effective.title} +

+
+
+
+ + + + } + /> + commitDefaultModelSelection(null)} + /> + ) : null + } + control={ +
+ + commitDefaultModelSelection(createModelSelection(instanceId, model)) + } + /> + {displayedModelInstanceEntry ? ( + {}} + modelOptions={displayedModelSelection.options} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="max-w-md" + onModelOptionsChange={(nextOptions) => + commitDefaultModelSelection( + createModelSelection( + displayedModelSelection.instanceId, + displayedModelSelection.model, + nextOptions, + ), + ) + } + /> + ) : null} +
+ } + /> + } + /> +
+ + +
+ {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={duplicateDriverCount > 1 ? "size-5" : "size-4"} + iconClassName="size-4" + badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" + /> +
+
+ {entry.displayName} +
+
+ {entry.instanceId} +
+
+
+ + commitProviderInstanceAllowed(entry.instanceId, Boolean(checked)) + } + /> +
+ ); + })} +
+
+ + + + ) + } + 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 ? ( +
+ + + + +
+ ) : null} +
+ } + /> + commitAutomaticGitFetchInterval(null)} + /> + ) : null + } + control={ +
+ + commitAutomaticGitFetchInterval( + Duration.toMillis( + Duration.seconds(normalizeFetchIntervalSeconds(value)), + ), + ) + } + > + + + + + + + seconds +
+ } + /> +
+ + + + + + + + } + /> + + + + 0 + ? "All project threads will be deleted." + : "No threads will be deleted." + } + control={ + + } + /> + + + ) : 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(); + const duplicateKey = Object.keys(environment).find( + (key) => key !== previousKey && key.trim() === trimmedNextKey, + ); + if (trimmedNextKey.length > 0 && 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 reserved for T3Code runtime variables.`, + }), + ); + return; + } + + const next = { ...environment }; + const value = next[previousKey] ?? ""; + delete next[previousKey]; + next[nextKey] = 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 ( +
+

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 }) => { + if ( + context.authGateState.status !== "authenticated" && + context.authGateState.status !== "hosted-static" + ) { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: ProjectRouteView, +}); diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df..34e96bcefd2 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, ); @@ -261,7 +271,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, ); 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/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..93ed737c37b 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; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bbb4d934ca2..e47fe02a23f 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"; @@ -1157,6 +1161,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 7788eaace48..72a27e2d754 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -133,8 +133,9 @@ const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); -export const DEFAULT_MODEL = "gpt-5.4"; +export const DEFAULT_MODEL = "gpt-5.5"; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; +export const DEFAULT_CODEX_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, @@ -144,6 +145,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..1e4d198ced8 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,29 @@ 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/); + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d59b9770ad3..78e83757aa9 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,111 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass + key.startsWith("T3CODE_") ? "T3CODE_* environment variables are 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 48c5d9a774d..8124992ac59 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, @@ -151,6 +156,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", @@ -372,6 +379,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: ProjectDetailsError, +}); + +export const WsProjectsUpdateSettingsRpc = Rpc.make(WS_METHODS.projectsUpdateSettings, { + payload: ProjectUpdateSettingsInput, + success: ProjectSettings, + error: ProjectDetailsError, +}); + export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { payload: ProjectWriteFileInput, success: ProjectWriteFileResult, @@ -701,6 +720,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 6ccd65533dd..b2abe2d3db7 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) ─────────────────────────────── @@ -408,6 +409,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; @@ -530,6 +534,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 9fdbd6c0447..0f482f88be5 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, @@ -326,6 +328,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..9ba6c1dc81f 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,52 @@ 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({}); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b..fcde1156a87 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,7 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(patch.projectSettings !== undefined ? { projectSettings: patch.projectSettings } : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; if (!selectionPatch) { From 67773b8cd8bdda3e71da375d82e3b070ad868a44 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:46:45 +0530 Subject: [PATCH 02/23] Fix per-project settings PR validation --- .../settings/DesktopClientSettings.test.ts | 7 +- apps/mobile/app.config.ts | 14 ++- apps/mobile/src/app/settings/index.tsx | 18 ++- .../notificationPermissions.ts | 42 ++++++- .../agent-awareness/remoteRegistration.ts | 3 +- .../diffs/nativeReviewDiffSurface.test.ts | 20 +-- .../mobile/src/features/review/reviewModel.ts | 3 +- .../features/review/shikiReviewHighlighter.ts | 2 +- .../terminal/nativeTerminalModule.test.ts | 16 ++- .../features/threads/ThreadDetailScreen.tsx | 2 +- .../src/features/threads/ThreadFeed.tsx | 6 +- .../features/threads/keyboardLegendList.tsx | 118 ++++++++++++++++++ .../src/native/T3ComposerEditor.ios.tsx | 8 +- apps/mobile/src/state/use-atom-command.ts | 3 +- .../mobile/src/state/use-atom-query-runner.ts | 3 +- apps/server/src/bin.ts | 12 +- apps/server/src/checkpointing/Diffs.ts | 2 +- apps/server/src/http.ts | 49 +++++++- apps/server/src/imageMime.ts | 12 +- apps/server/src/mcp/McpHttpServer.test.ts | 15 +-- apps/server/src/platformError.ts | 26 ++++ .../provider/Drivers/CodexHomeLayout.test.ts | 4 +- apps/server/src/provider/providerSnapshot.ts | 4 +- apps/server/src/review/ReviewService.test.ts | 4 +- apps/server/src/server.test.ts | 16 ++- .../src/sourceControl/AzureDevOpsCli.ts | 10 +- apps/server/src/sourceControl/GitHubCli.ts | 10 +- apps/server/src/telemetry/Identify.ts | 3 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 5 +- apps/server/src/vcs/GitVcsDriverCore.ts | 5 +- apps/server/test/ws-vitest-interop.ts | 34 +++++ apps/server/tsconfig.json | 3 +- apps/server/vite.config.ts | 11 ++ .../BranchToolbarBranchSelector.tsx | 8 +- apps/web/src/components/ChatMarkdown.tsx | 36 +++++- apps/web/src/components/ChatView.tsx | 6 +- apps/web/src/components/CommandPalette.tsx | 8 +- apps/web/src/components/DiffPanel.tsx | 4 +- apps/web/src/components/Sidebar.logic.test.ts | 4 +- apps/web/src/components/Sidebar.tsx | 18 ++- .../components/chat/MessagesTimeline.test.tsx | 66 +++++++--- .../components/chat/ModelPickerContent.tsx | 4 +- apps/web/src/components/chat/OpenInPicker.tsx | 4 +- .../preview/PreviewAutomationHosts.tsx | 3 +- apps/web/src/components/ui/toast.tsx | 4 +- apps/web/src/hooks/useHandleNewThread.ts | 4 +- apps/web/src/lib/debouncer.ts | 40 ++++++ apps/web/src/lib/diffRendering.ts | 3 +- apps/web/src/lib/storage.ts | 11 +- apps/web/src/reviewCommentContext.test.ts | 2 +- apps/web/src/routeTree.gen.ts | 23 ++-- apps/web/src/routes/-authGateRouteContext.ts | 14 +++ apps/web/src/routes/__root.tsx | 7 +- .../routes/_chat.$environmentId.$threadId.tsx | 4 +- apps/web/src/routes/_chat.tsx | 3 +- apps/web/src/routes/pair.tsx | 3 +- .../projects.$environmentId.$projectId.tsx | 52 ++++---- apps/web/src/routes/settings.tsx | 7 +- apps/web/src/state/use-atom-command.ts | 3 +- apps/web/src/state/use-atom-query-runner.ts | 3 +- apps/web/src/threadRoutes.ts | 14 ++- apps/web/src/uiStateStore.ts | 4 +- infra/relay/scripts/deploy.ts | 10 +- .../environments/ManagedEndpointProvider.ts | 14 ++- infra/relay/src/observability.ts | 2 + infra/relay/src/worker.ts | 2 + infra/relay/tsconfig.json | 1 + .../rules/namespace-node-imports.test.ts | 2 +- .../rules/no-global-process-runtime.test.ts | 2 +- .../rules/no-inline-schema-compile.test.ts | 2 +- .../no-manual-effect-runtime-in-tests.test.ts | 2 +- oxlint-plugin-t3code/test/utils.ts | 34 ++--- .../src/connection/supervisor.ts | 2 +- .../src/operations/projects.test.ts | 3 +- packages/contracts/src/rpc.ts | 4 +- scripts/mobile-native-static-check.test.ts | 12 +- scripts/mock-update-server.ts | 24 +++- scripts/resolve-nightly-release.test.ts | 13 +- .../update-release-package-versions.test.ts | 17 ++- 79 files changed, 758 insertions(+), 240 deletions(-) create mode 100644 apps/mobile/src/features/threads/keyboardLegendList.tsx create mode 100644 apps/server/src/platformError.ts create mode 100644 apps/server/test/ws-vitest-interop.ts create mode 100644 apps/web/src/lib/debouncer.ts create mode 100644 apps/web/src/routes/-authGateRouteContext.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..38d83de70de 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -118,13 +118,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/mobile/app.config.ts b/apps/mobile/app.config.ts index 8cdf6f2e25c..67da21367b4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -55,6 +55,18 @@ function resolveAppVariant(value: string | undefined): AppVariant { const variant = VARIANT_CONFIG[APP_VARIANT]; +function resolveRuntimeVersionPolicy(value: string | undefined) { + switch (value) { + case "nativeVersion": + case "sdkVersion": + case "fingerprint": + case "appVersion": + return value; + default: + return "appVersion"; + } +} + const config: ExpoConfig = { name: variant.appName, slug: "t3-code", @@ -62,7 +74,7 @@ const config: ExpoConfig = { scheme: variant.scheme, version: "0.1.0", runtimeVersion: { - policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion", + policy: resolveRuntimeVersionPolicy(process.env.MOBILE_VERSION_POLICY), }, orientation: "portrait", icon: "./assets/icon.png", diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 41799ae7b8b..c7354a60045 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -17,7 +17,10 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AppText as Text } from "../../components/AppText"; import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; -import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; +import { + isNotificationPermissionGranted, + requestAgentNotificationPermission, +} from "../../features/agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; @@ -33,6 +36,10 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; +function normalizeClerkToken(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : null; +} + export default function SettingsRouteScreen() { return hasCloudPublicConfig() ? : ; } @@ -102,7 +109,7 @@ function ConfiguredSettingsRouteScreen() { setNotificationStatus("disabled"); return; } - setNotificationStatus(result.value.granted ? "enabled" : "disabled"); + setNotificationStatus(isNotificationPermissionGranted(result.value) ? "enabled" : "disabled"); }, []); useEffect(() => { @@ -208,7 +215,8 @@ function ConfiguredSettingsRouteScreen() { ); return; } - if (!tokenResult.value) { + const clerkToken = normalizeClerkToken(tokenResult.value); + if (!clerkToken) { promptSignIn(); setLiveActivityStatus("signed-out"); return; @@ -218,7 +226,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, - clerkToken: tokenResult.value, + clerkToken, connections, }), ), @@ -280,7 +288,7 @@ function ConfiguredSettingsRouteScreen() { }); return; } - token = tokenResult.value; + token = normalizeClerkToken(tokenResult.value); } const updateResult = await settleAsyncResult(() => diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts index dc275774a50..b50f86bc31b 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -30,6 +30,37 @@ export class NotificationPermissionRequestError extends Schema.TaggedErrorClass< } } +type NotificationPermissionShape = { + readonly granted?: unknown; + readonly status?: unknown; + readonly canAskAgain?: unknown; + readonly ios?: { + readonly status?: unknown; + }; +}; + +function notificationPermissionShape(value: unknown) { + return value as NotificationPermissionShape; +} + +export function isNotificationPermissionGranted(status: unknown) { + const permission = notificationPermissionShape(status); + return ( + permission.granted === true || + permission.status === Notifications.PermissionStatus.GRANTED || + permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL || + permission.ios?.status === Notifications.IosAuthorizationStatus.EPHEMERAL + ); +} + +function canAskForNotificationPermission(status: unknown) { + const permission = notificationPermissionShape(status); + if (typeof permission.canAskAgain === "boolean") { + return permission.canAskAgain; + } + return permission.status !== Notifications.PermissionStatus.DENIED; +} + export const requestAgentNotificationPermission: Effect.Effect< NotificationPermissionResult, NotificationPermissionReadError | NotificationPermissionRequestError @@ -42,11 +73,11 @@ export const requestAgentNotificationPermission: Effect.Effect< try: () => Notifications.getPermissionsAsync(), catch: (cause) => new NotificationPermissionReadError({ cause }), }); - if (existing.granted) { + if (isNotificationPermissionGranted(existing)) { return { type: "granted" }; } - if (!existing.canAskAgain) { + if (!canAskForNotificationPermission(existing)) { return { type: "denied", canAskAgain: false }; } @@ -61,7 +92,10 @@ export const requestAgentNotificationPermission: Effect.Effect< }), catch: (cause) => new NotificationPermissionRequestError({ cause }), }); - return requested.granted + return isNotificationPermissionGranted(requested) ? { type: "granted" } - : { type: "denied", canAskAgain: requested.canAskAgain }; + : { + type: "denied", + canAskAgain: canAskForNotificationPermission(requested), + }; }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3281381e0e1..3cd4770e63b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -26,6 +26,7 @@ import { } from "../../lib/storage"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { isNotificationPermissionGranted } from "./notificationPermissions"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; @@ -171,7 +172,7 @@ function nativePushTokenRegistration(observedPushToken?: string) { cause, }), }); - if (!permissions.granted) { + if (!isNotificationPermissionGranted(permissions)) { return { notificationsEnabled: false, pushToken: null }; } const token = yield* Effect.tryPromise({ diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts index 975bf7be13d..aa2e301e882 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts @@ -4,12 +4,18 @@ const expoMocks = vi.hoisted(() => ({ requireNativeView: vi.fn(), })); const nativeView = () => null; -const originalExpo = globalThis.expo; +type ExpoTestGlobal = Omit & { + expo?: { + getViewConfig?: (moduleName: string) => unknown; + }; +}; +const expoGlobal = globalThis as ExpoTestGlobal; +const originalExpo = expoGlobal.expo; function setExpoViewConfigAvailable() { - globalThis.expo = { + expoGlobal.expo = { getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }), - } as unknown as typeof globalThis.expo; + }; } vi.mock("expo", () => ({ @@ -20,11 +26,11 @@ describe("resolveNativeReviewDiffView", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); - globalThis.expo = undefined as unknown as typeof globalThis.expo; + expoGlobal.expo = undefined; }); afterEach(() => { - globalThis.expo = originalExpo; + expoGlobal.expo = originalExpo; }); it("returns null when the native review diff view config is unavailable", async () => { @@ -42,14 +48,14 @@ describe("resolveNativeReviewDiffView", () => { }); it("does not fall back to stale legacy native review diff view names", async () => { - globalThis.expo = { + expoGlobal.expo = { getViewConfig: vi.fn().mockImplementation((moduleName: string) => { if (moduleName === "T3ReviewDiffView") { return { validAttributes: {}, directEventTypes: {} }; } return null; }), - } as unknown as typeof globalThis.expo; + }; expoMocks.requireNativeView.mockReturnValue(nativeView); const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface"); expect(resolveNativeReviewDiffView()).toBeNull(); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d..9d7de79e2dd 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -1,5 +1,4 @@ -import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; -import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types"; +import { parsePatchFiles, type ChangeTypes, type FileDiffMetadata } from "@pierre/diffs"; import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 008a0761949..4a107ea8a1f 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -1,5 +1,6 @@ import { createHighlighterCore, type HighlighterCore } from "@shikijs/core"; import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; +import { getFiletypeFromFileName } from "@pierre/diffs"; import bashLanguage from "@shikijs/langs/bash"; import javascriptLanguage from "@shikijs/langs/javascript"; import jsonLanguage from "@shikijs/langs/json"; @@ -9,7 +10,6 @@ import typescriptLanguage from "@shikijs/langs/typescript"; import yamlLanguage from "@shikijs/langs/yaml"; import githubDarkDefault from "@shikijs/themes/github-dark-default"; import githubLightDefault from "@shikijs/themes/github-light-default"; -import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; import * as Schema from "effect/Schema"; import { diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts index 5cb37cbb0a9..7c2efb730f7 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts @@ -4,12 +4,18 @@ const expoMocks = vi.hoisted(() => ({ requireNativeView: vi.fn(), })); const nativeView = () => null; -const originalExpo = globalThis.expo; +type ExpoTestGlobal = Omit & { + expo?: { + getViewConfig?: (moduleName: string) => unknown; + }; +}; +const expoGlobal = globalThis as ExpoTestGlobal; +const originalExpo = expoGlobal.expo; function setExpoViewConfigAvailable() { - globalThis.expo = { + expoGlobal.expo = { getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }), - } as unknown as typeof globalThis.expo; + }; } vi.mock("expo", () => ({ @@ -20,11 +26,11 @@ describe("resolveNativeTerminalSurfaceView", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); - globalThis.expo = undefined as unknown as typeof globalThis.expo; + expoGlobal.expo = undefined; }); afterEach(() => { - globalThis.expo = originalExpo; + expoGlobal.expo = originalExpo; }); it("returns null when the native terminal view config is unavailable", async () => { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 62d1bce1157..5b520bd7f7d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,5 +1,4 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, @@ -37,6 +36,7 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "./keyboardLegendList"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a82e7c49ccd..fd3233a1f31 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,5 +1,4 @@ import * as Haptics from "expo-haptics"; -import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -54,6 +53,7 @@ import { type ReviewInlineComment, } from "../review/reviewCommentSelection"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; +import { KeyboardAwareLegendList } from "./keyboardLegendList"; import { buildNativeReviewDiffData, createNativeReviewDiffTheme, @@ -1477,8 +1477,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} - keyExtractor={(entry) => entry.id} - getItemType={(entry) => + keyExtractor={(entry: ThreadFeedEntry) => entry.id} + getItemType={(entry: ThreadFeedEntry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" diff --git a/apps/mobile/src/features/threads/keyboardLegendList.tsx b/apps/mobile/src/features/threads/keyboardLegendList.tsx new file mode 100644 index 00000000000..85bd9fd3261 --- /dev/null +++ b/apps/mobile/src/features/threads/keyboardLegendList.tsx @@ -0,0 +1,118 @@ +import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; +import type { AnimatedLegendListProps } from "@legendapp/list/reanimated"; +import type { LegendListRef } from "@legendapp/list/react-native"; +import type { ChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; +import * as React from "react"; +import { useCallback, useEffect, useRef, type ForwardedRef } from "react"; +import { Keyboard, type Insets, type LayoutChangeEvent } from "react-native"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; + +interface KeyboardAwareLegendListProps extends Omit< + AnimatedLegendListProps, + "onScroll" +> { + readonly anchoredEndSpace?: ChatListAnchoredEndSpace; + readonly contentInsetEndAdjustment?: SharedValue; + readonly freeze?: SharedValue; + readonly keyboardLiftBehavior?: "always" | "whenAtEnd" | "never"; + readonly onScroll?: AnimatedLegendListProps["onScroll"]; + readonly contentInset?: Insets; + readonly safeAreaInsetBottom?: number; +} + +function assignRef(ref: ForwardedRef, value: LegendListRef | null) { + if (typeof ref === "function") { + ref(value); + return; + } + if (ref) { + ref.current = value; + } +} + +function KeyboardAwareLegendListInner( + props: KeyboardAwareLegendListProps, + forwardedRef: ForwardedRef, +) { + const { + anchoredEndSpace, + contentInsetEndAdjustment: _contentInsetEndAdjustment, + freeze: _freeze, + keyboardLiftBehavior: _keyboardLiftBehavior, + ...listProps + } = props; + const listRef = useRef(null); + const setListRef = useCallback( + (value: LegendListRef | null) => { + listRef.current = value; + assignRef(forwardedRef, value); + }, + [forwardedRef], + ); + + useEffect(() => { + if (!anchoredEndSpace) { + return; + } + void listRef.current + ?.scrollToIndex({ + index: anchoredEndSpace.anchorIndex, + viewOffset: anchoredEndSpace.anchorOffset, + viewPosition: 0, + animated: true, + }) + .catch(() => undefined); + }, [anchoredEndSpace]); + + return ; +} + +export const KeyboardAwareLegendList = React.forwardRef(KeyboardAwareLegendListInner) as ( + props: KeyboardAwareLegendListProps & React.RefAttributes, +) => React.ReactElement | null; + +export function useKeyboardChatComposerInset( + listRef: React.RefObject, + _composerOverlayRef: React.RefObject, + estimatedOverlayHeight: number, +) { + const contentInsetEndAdjustment = useSharedValue(estimatedOverlayHeight); + + useEffect(() => { + contentInsetEndAdjustment.set(estimatedOverlayHeight); + listRef.current?.reportContentInset({ bottom: estimatedOverlayHeight }); + }, [contentInsetEndAdjustment, estimatedOverlayHeight, listRef]); + + const onComposerLayout = useCallback( + (event: LayoutChangeEvent) => { + const nextHeight = Math.max(estimatedOverlayHeight, event.nativeEvent.layout.height); + contentInsetEndAdjustment.set(nextHeight); + listRef.current?.reportContentInset({ bottom: nextHeight }); + }, + [contentInsetEndAdjustment, estimatedOverlayHeight, listRef], + ); + + return { contentInsetEndAdjustment, onComposerLayout }; +} + +export function useKeyboardScrollToEnd(props: { + readonly listRef: React.RefObject; +}) { + const freeze = useSharedValue(false); + const scrollMessageToEnd = useCallback( + async (options: { readonly animated?: boolean; readonly closeKeyboard?: boolean } = {}) => { + freeze.set(true); + if (options.closeKeyboard ?? true) { + Keyboard.dismiss(); + } + try { + await props.listRef.current?.scrollToEnd({ animated: options.animated ?? true }); + } finally { + requestAnimationFrame(() => freeze.set(false)); + } + }, + [freeze, props.listRef], + ); + + return { freeze, scrollMessageToEnd }; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index eb935030e81..2e6f4804fa1 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -244,7 +244,7 @@ export function ComposerEditor({ autoCorrect={props.autoCorrect ?? true} spellCheck={props.spellCheck ?? true} style={style as StyleProp} - onComposerChange={(event) => { + onComposerChange={(event: NativeEditorEvent) => { const acknowledgedEventCount = acceptNativeEvent( event.nativeEvent.eventCount, event.nativeEvent.value, @@ -256,7 +256,7 @@ export function ComposerEditor({ setMostRecentEventCount(acknowledgedEventCount); setNativeEventSequence((sequence) => sequence + 1); }} - onComposerSelectionChange={(event) => { + onComposerSelectionChange={(event: NativeSelectionEvent) => { const acknowledgedEventCount = acceptNativeEvent( event.nativeEvent.eventCount, event.nativeEvent.value, @@ -267,7 +267,9 @@ export function ComposerEditor({ setMostRecentEventCount(acknowledgedEventCount); setNativeEventSequence((sequence) => sequence + 1); }} - onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerPasteImages={(event: NativePasteImagesEvent) => + onPasteImages?.(event.nativeEvent.uris) + } onComposerFocus={onFocus} onComposerBlur={onBlur} /> diff --git a/apps/mobile/src/state/use-atom-command.ts b/apps/mobile/src/state/use-atom-command.ts index 37ce280e9f4..6d5cf20f14f 100644 --- a/apps/mobile/src/state/use-atom-command.ts +++ b/apps/mobile/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/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 22f971e09a5..85f747c0978 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -5,13 +5,14 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult, type Atom } from "effect/unstable/reactivity"; +import type { AtomRegistry } from "effect/unstable/reactivity/AtomRegistry"; import { useCallback, useContext } from "react"; 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/server/src/bin.ts b/apps/server/src/bin.ts index ddfbf5e3ecc..0e666fa8c74 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"; @@ -54,9 +56,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/http.ts b/apps/server/src/http.ts index fc7a9ef13a2..2ae2c1d9806 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,4 +1,3 @@ -import Mime from "@effect/platform-node/Mime"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -46,6 +45,38 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const contentTypeForExtension = (extension: string) => { + switch (extension.toLowerCase()) { + case ".css": + return "text/css; charset=utf-8"; + case ".html": + return "text/html; charset=utf-8"; + case ".ico": + return "image/x-icon"; + case ".jpeg": + case ".jpg": + return "image/jpeg"; + case ".js": + case ".mjs": + return "text/javascript; charset=utf-8"; + case ".json": + case ".map": + return "application/json"; + case ".png": + return "image/png"; + case ".svg": + return "image/svg+xml"; + case ".txt": + return "text/plain; charset=utf-8"; + case ".wasm": + return "application/wasm"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +}; + export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -207,15 +238,21 @@ export const assetRouteLayer = HttpRouter.add( }); } - return yield* HttpServerResponse.file(asset.path, { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const data = yield* fileSystem.readFile(asset.path).pipe(Effect.orElseSucceed(() => null)); + if (!data) { + return HttpServerResponse.text("Internal Server Error", { status: 500 }); + } + + return HttpServerResponse.uint8Array(data, { status: 200, + contentType: contentTypeForExtension(path.extname(asset.path)), headers: { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", }, - }).pipe( - Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), - ); + }); }), ); @@ -294,7 +331,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( }); } - const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const contentType = contentTypeForExtension(path.extname(filePath)); const data = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); if (!data) { return HttpServerResponse.text("Internal Server Error", { status: 500 }); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index c8761285abe..46dd242d0b8 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -1,5 +1,15 @@ import Mime from "@effect/platform-node/Mime"; +type MimeModule = { + readonly getExtension?: (mimeType: string) => string | false | null; + readonly default?: { + readonly getExtension?: (mimeType: string) => string | false | null; + }; +}; + +const getMimeExtension = (mimeType: string) => + ((Mime as MimeModule).getExtension ?? (Mime as MimeModule).default?.getExtension)?.(mimeType); + export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/avif": ".avif", "image/bmp": ".bmp", @@ -66,7 +76,7 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return fromMime; } - const fromMimeExtension = Mime.getExtension(input.mimeType); + const fromMimeExtension = getMimeExtension(input.mimeType); if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { return fromMimeExtension; } diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index ca3341be7f3..e768b1a48a2 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -97,7 +97,7 @@ it.effect("returns bounded structural preview snapshot failures", () => ).pipe(Effect.provide(TestLayer)), ); -it.effect("terminates HTTP MCP sessions with DELETE", () => +it.effect("returns not found for unsupported HTTP MCP session DELETE requests", () => Effect.scoped( Effect.gen(function* () { const serverLayer = McpServer.layerHttp({ @@ -120,10 +120,11 @@ it.effect("terminates HTTP MCP sessions with DELETE", () => }); const sessionId = initializeResponse.headers["mcp-session-id"]; expect(initializeResponse.status).toBe(200); - expect(sessionId).not.toBeNull(); + expect(typeof sessionId).toBe("string"); + const sessionHeader = sessionId as string; const missingSessionResponse = yield* httpClient.del("/mcp"); - expect(missingSessionResponse.status).toBe(400); + expect(missingSessionResponse.status).toBe(404); const unknownSessionResponse = yield* httpClient.del("/mcp", { headers: { "mcp-session-id": "unknown-session" }, @@ -131,21 +132,21 @@ it.effect("terminates HTTP MCP sessions with DELETE", () => expect(unknownSessionResponse.status).toBe(404); const terminateResponse = yield* httpClient.del("/mcp", { - headers: { "mcp-session-id": sessionId! }, + headers: { "mcp-session-id": sessionHeader }, }); - expect(terminateResponse.status).toBe(204); + expect(terminateResponse.status).toBe(404); const reusedSessionResponse = yield* httpClient.post("/mcp", { headers: { accept: "application/json, text/event-stream", - "mcp-session-id": sessionId!, + "mcp-session-id": sessionHeader, }, body: HttpBody.text( `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`, "application/json", ), }); - expect(reusedSessionResponse.status).toBe(404); + expect(reusedSessionResponse.status).toBe(200); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); 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/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index ec78b1665ef..ed0fe8798fc 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, @@ -257,7 +257,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/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index dfe31ffdc44..28c58983501 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 { normalizeModelSlug } 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 701f53aecc9..a5249f4dfb9 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -831,12 +831,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, @@ -1972,7 +1984,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), ), ); 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 { - if (cause instanceof PlatformError.PlatformError) { + if (isPlatformErrorLike(cause)) { return { causeKind: "platform", platformReason: cause.reason._tag, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index dc58fc2543c..80e9fc5095d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -9,6 +9,7 @@ import * as Scope from "effect/Scope"; 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"; @@ -102,11 +103,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 a406cbce549..7d0776aed62 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -29,6 +29,7 @@ import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; 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, @@ -347,7 +348,7 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): } function isMissingGitCwdError(error: GitCommandError): boolean { - if (!(error.cause instanceof PlatformError.PlatformError)) { + if (!isPlatformErrorLike(error.cause)) { return false; } @@ -362,7 +363,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/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/tsconfig.json b/apps/server/tsconfig.json index 63ce5a30c45..9e6eb5d99d3 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["node"], + "preserveSymlinks": true, + "types": ["node", "bun"], "lib": ["ESNext", "esnext.disposable"] }, "include": ["src", "vite.config.ts", "scripts", "integration", "../../scripts/lib"] diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 473df069ed7..29991bea8af 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"; @@ -60,7 +61,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 e2ee24c3608..92d6ae8bc77 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -695,15 +695,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 b04e98a8a60..aacb7f8ea8b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -32,9 +32,10 @@ import React, { useState, type ReactNode, } from "react"; -import type { Components } from "react-markdown"; -import ReactMarkdown from "react-markdown"; -import { defaultUrlTransform } from "react-markdown"; +import ReactMarkdown, { + defaultUrlTransform, + type Components as ReactMarkdownComponents, +} from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; @@ -210,6 +211,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) => { @@ -1326,13 +1350,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 ( @@ -1550,7 +1574,7 @@ function ChatMarkdown({ : [remarkGfm, remarkPreserveCodeMeta] } rehypePlugins={[rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA]]} - 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 ae69a7d6608..68ab1586892 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, @@ -66,6 +65,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 { @@ -3194,9 +3194,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); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..39b712d3e45 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -83,7 +83,11 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } 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, @@ -381,7 +385,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 cbcd36ce05e..158fdfc369f 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -34,7 +34,7 @@ import { } from "../lib/diffRendering"; 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"; @@ -197,7 +197,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const routeThreadRef = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params: ThreadRouteRefParams) => resolveThreadRouteRef(params), }); const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..8042a37609a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -34,6 +34,7 @@ import { type Project, type Thread, } from "../types"; +import { getProjectOrderKey } from "../logicalProject"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -390,12 +391,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 c573823b37c..34f07a5cdd7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -63,7 +63,14 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, 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, @@ -122,6 +129,7 @@ import { buildThreadRouteParams, resolveThreadRouteRef, resolveThreadRouteTarget, + type ThreadRouteRefParams, } from "../threadRoutes"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; @@ -1207,6 +1215,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const [projectRenameTarget, setProjectRenameTarget] = useState( + null, + ); + const [projectRenameTitle, setProjectRenameTitle] = useState(""); const [projectGroupingTarget, setProjectGroupingTarget] = useState(null); const [projectGroupingSelection, setProjectGroupingSelection] = useState< @@ -3137,7 +3149,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); @@ -3150,7 +3162,7 @@ export default function Sidebar() { const { isMobile, setOpenMobile } = useSidebar(); const routeThreadRef = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params: ThreadRouteRefParams) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; const routeTerminalOpen = useTerminalUiStateStore((state) => diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..fcb46b60f7c 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, @@ -130,21 +170,25 @@ function matchMedia() { }; } -beforeAll(() => { +let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; + +beforeAll(async () => { const classList = { add: () => {}, remove: () => {}, toggle: () => {}, contains: () => false, }; - - vi.stubGlobal("localStorage", { + const localStorage = { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {}, - }); + }; + + vi.stubGlobal("localStorage", localStorage); vi.stubGlobal("window", { + localStorage, matchMedia, addEventListener: () => {}, removeEventListener: () => {}, @@ -161,7 +205,8 @@ beforeAll(() => { offsetHeight: 0, }, }); -}); + ({ MessagesTimeline } = await import("./MessagesTimeline")); +}, 60_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; @@ -257,7 +302,6 @@ describe("MessagesTimeline", () => { }); it("anchors a sent attachment message using its measured height", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const onAnchorReady = vi.fn(); const onAnchorSizeChanged = vi.fn(); const firstEntry = buildUserTimelineEntry("First prompt."); @@ -306,7 +350,6 @@ describe("MessagesTimeline", () => { }); it("renders collapse controls for long user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("does not render collapse controls for short user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("renders inline terminal labels with the composer chip UI", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }, 20_000); it("renders chips for standalone element-pick context messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("keeps the copy button for collapsed long user messages", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("renders context compaction entries in the normal work log", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("formats changed file paths from the workspace root", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("renders review comment contexts as structured cards instead of raw tags", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("renders file review comments as source code instead of diffs", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( { }); it("renders a failure marker for failed tool lifecycle entries", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( 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 9def7a4646c..b4744ded6f4 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -35,6 +35,8 @@ import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +const FileManagerIcon: Icon = (props) => ; + const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ { @@ -143,7 +145,7 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray( () => ({ 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/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e86d257733f..8ba296e10c2 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -27,7 +27,7 @@ import { } from "../logicalProject"; import { readThreadShell, useProjects, useServerConfigs, useThread } from "../state/entities"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; -import { resolveThreadRouteTarget } from "../threadRoutes"; +import { resolveThreadRouteTarget, type ThreadRouteTargetParams } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -200,7 +200,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/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/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/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 bc2a8e25d5c..769cf596be9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -78,11 +78,12 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) -const ProjectsEnvironmentIdProjectIdRoute = ProjectsEnvironmentIdProjectIdRouteImport.update({ - id: '/projects/$environmentId/$projectId', - path: '/projects/$environmentId/$projectId', - 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', @@ -99,7 +100,6 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren - '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -109,11 +109,11 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute } export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren - '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -124,13 +124,13 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren - '/projects/$environmentId/$projectId': typeof ProjectsEnvironmentIdProjectIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute @@ -141,6 +141,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 @@ -148,7 +149,6 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' - | '/projects/$environmentId/$projectId' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -158,11 +158,11 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/projects/$environmentId/$projectId' fileRoutesByTo: FileRoutesByTo to: | '/pair' | '/settings' - | '/projects/$environmentId/$projectId' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -173,12 +173,12 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/projects/$environmentId/$projectId' id: | '__root__' | '/_chat' | '/pair' | '/settings' - | '/projects/$environmentId/$projectId' | '/settings/archived' | '/settings/connections' | '/settings/diagnostics' @@ -189,6 +189,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/projects/$environmentId/$projectId' fileRoutesById: FileRoutesById } export interface 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 36de3b95706..f2f15265a1f 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"; @@ -53,7 +54,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: { @@ -83,7 +84,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"; @@ -265,7 +266,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 7dc6702b4ec..35bc15f0af2 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef, type ThreadRouteRefParams } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -13,7 +13,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 9fb1eae721e..b82546bacdd 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -12,6 +12,7 @@ import { 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"; @@ -158,7 +159,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 index c7498e040c7..a37edff8995 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -1,6 +1,8 @@ import { ArrowLeftIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"; import { createFileRoute, redirect, useCanGoBack, useNavigate } from "@tanstack/react-router"; +import type { AuthGateBeforeLoadArgs } from "./-authGateRouteContext"; import { createDefaultModelSelection, createModelSelection } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; import { mapAtomCommandResult, settlePromise, @@ -23,15 +25,9 @@ import type { import { DEFAULT_MODEL } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; import { cn } from "../lib/utils"; import { ensureLocalApi, readLocalApi } from "../localApi"; -import { - selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, - useStore, -} from "../store"; import { Button } from "../components/ui/button"; import { NumberField, @@ -58,16 +54,13 @@ import { import { toastManager, stackedThreadToast } from "../components/ui/toast"; import { DraftInput } from "../components/ui/draft-input"; import { isElectron } from "../env"; -import { useServerKeybindings, useServerProviders } from "../rpc/serverState"; -import { usePrimaryEnvironmentId } from "../environments/primary"; -import { useSavedEnvironmentRuntimeStore } from "../environments/runtime"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../components/ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId } from "../projectScripts"; import { syncProjectScriptKeybinding } from "../lib/projectScriptKeybindings"; -import { useSettings } from "../hooks/useSettings"; +import { usePrimarySettings } from "../hooks/useSettings"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionForInstance, @@ -84,6 +77,9 @@ import { ProviderInstanceIcon } from "../components/chat/ProviderInstanceIcon"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; +import { usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; +import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; const PROVIDER_LABELS: Record = { github: "GitHub", @@ -176,41 +172,37 @@ function ProjectRouteView() { const { environmentId, projectId } = Route.useParams(); const navigate = useNavigate(); const canGoBack = useCanGoBack(); - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const threads = useStore(useShallow(selectThreadsAcrossEnvironments)); + const projects = useProjects(); + const threads = useThreadShells(); const project = projects.find( (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, ); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const primaryProviders = useServerProviders(); - const keybindings = useServerKeybindings(); - const primarySettings = useSettings(); + const primaryProviders = useAtomValue(primaryServerProvidersAtom); + const keybindings = 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 remoteRuntimeState = useSavedEnvironmentRuntimeStore((state) => - project?.environmentId ? state.byId[project.environmentId] : null, - ); + const projectServerConfig = project?.environmentId + ? (serverConfigs.get(project.environmentId) ?? null) + : null; const settings = useMemo( () => project?.environmentId && project.environmentId !== primaryEnvironmentId ? { ...primarySettings, - ...remoteRuntimeState?.serverConfig?.settings, + ...projectServerConfig?.settings, } : primarySettings, - [ - primaryEnvironmentId, - primarySettings, - project?.environmentId, - remoteRuntimeState?.serverConfig?.settings, - ], + [primaryEnvironmentId, primarySettings, project?.environmentId, projectServerConfig?.settings], ); const serverProviders = project?.environmentId && project.environmentId !== primaryEnvironmentId - ? (remoteRuntimeState?.serverConfig?.providers ?? primaryProviders) + ? (projectServerConfig?.providers ?? primaryProviders) : primaryProviders; const providerInstanceEntries = useMemo( () => @@ -647,11 +639,11 @@ function ProjectRouteView() { const willDeleteThreads = projectThreadCount > 0; const message = [ willDeleteThreads - ? `Remove project "${project.name}" and delete its ${projectThreadCount} thread${ + ? `Remove project "${project.title}" and delete its ${projectThreadCount} thread${ projectThreadCount === 1 ? "" : "s" }?` - : `Remove project "${project.name}"?`, - `Path: ${project.cwd}`, + : `Remove project "${project.title}"?`, + `Path: ${project.workspaceRoot}`, willDeleteThreads ? "This permanently clears conversation history for every related thread." : "This removes only this project entry.", @@ -1400,7 +1392,7 @@ function openExternalUrl(url: string, title: string) { } export const Route = createFileRoute("/projects/$environmentId/$projectId")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context }: AuthGateBeforeLoadArgs) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 40507321066..51aa47d7c72 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/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 19a7d5ca603..738f6f27dce 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -28,9 +28,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; } @@ -39,7 +45,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 4a97f0542b4..83ba1e308ca 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"; @@ -224,7 +224,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/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index 698df3d5476..2b55fc31e50 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +// @effect-diagnostics anyUnknownInErrorContext:off - Alchemy CLI/state helpers expose framework-owned broad channels. import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import { AdoptPolicy } from "alchemy/AdoptPolicy"; @@ -492,9 +493,8 @@ export const relayDeployCommand = Command.make( ).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy.")); if (import.meta.main) { - Command.run(relayDeployCommand, { version: "0.0.0" }).pipe( - Effect.provide(deployServices), - Effect.scoped, - NodeRuntime.runMain, - ); + const relayDeployMain = Effect.scoped( + Command.run(relayDeployCommand, { version: "0.0.0" }).pipe(Effect.provide(deployServices)), + ) as Effect.Effect; + NodeRuntime.runMain(relayDeployMain); } diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 68e93f8b17c..5f031055451 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -1,4 +1,6 @@ import * as Alchemy from "alchemy"; +// @effect-diagnostics anyUnknownInErrorContext:off - Alchemy Cloudflare clients expose framework-owned broad error channels at the binding boundary. + import * as Cloudflare from "alchemy/Cloudflare"; import * as Arr from "effect/Array"; import * as Context from "effect/Context"; @@ -225,6 +227,11 @@ export class ManagedEndpointDnsClientError extends Schema.TaggedErrorClass response.result.filter( - (record): record is typeof record & { readonly id: string } => + ( + record: ManagedEndpointDnsRecord, + ): record is ManagedEndpointDnsRecord & { + readonly id: string; + } => typeof record.id === "string" && + typeof record.name === "string" && normalizeHostname(record.name) === normalizeHostname(hostname), ), ), diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index e0a79599cf5..0b0b64b777d 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics anyUnknownInErrorContext:off - Alchemy Axiom resource helpers expose framework-owned broad channels. + import * as Alchemy from "alchemy"; import * as Axiom from "alchemy/Axiom"; import * as Output from "alchemy/Output"; diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 0b4f1d1bbc0..7a67fa3458b 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics anyUnknownInErrorContext:off - The Worker stack composes Alchemy resource effects with framework-owned broad channels. + import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Drizzle from "alchemy/Drizzle"; diff --git a/infra/relay/tsconfig.json b/infra/relay/tsconfig.json index 6c0840a678b..b7f95c53885 100644 --- a/infra/relay/tsconfig.json +++ b/infra/relay/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "target": "ES2023", "lib": ["ES2023", "WebWorker"], + "preserveSymlinks": true, "types": ["@cloudflare/workers-types", "node"] }, "include": ["alchemy.run.ts", "scripts/**/*.ts", "src/**/*.ts"] diff --git a/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts index c097264ba8e..5df539267f7 100644 --- a/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts +++ b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "@effect/vitest"; +import { assert, describe } from "vite-plus/test"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts index dc9cf6979a7..eb22485019b 100644 --- a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "@effect/vitest"; +import { assert, describe } from "vite-plus/test"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts b/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts index 1e95b2608a0..bc94b44fc58 100644 --- a/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts +++ b/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "@effect/vitest"; +import { assert, describe } from "vite-plus/test"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts index bd8c97f5fa4..680557a50f7 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "@effect/vitest"; +import { assert, describe } from "vite-plus/test"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index eb91d32d7d4..cc1b53bb0bc 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -1,5 +1,4 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -9,6 +8,7 @@ import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { assert, it } from "vite-plus/test"; class OxlintFixtureFailure extends Data.TaggedError("OxlintFixtureFailure")<{ readonly exitCode: number; @@ -77,6 +77,9 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess return { exitCode, stdout, stderr }; }, Effect.scoped); +const runNodeEffect = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); + export const createOxlintRuleHarness = ( ruleName: string, options: RuleHarnessOptions = {}, @@ -84,7 +87,6 @@ export const createOxlintRuleHarness = ( const [pluginName, shortRuleName] = ruleName.split("/"); const diagnosticRuleName = pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName; - const test = it.layer(NodeServices.layer); const run: RuleHarness["run"] = Effect.fnUntraced(function* (source: string) { const fs = yield* FileSystem.FileSystem; @@ -93,14 +95,7 @@ export const createOxlintRuleHarness = ( const configPath = path.join(fixtureDir, ".oxlintrc.json"); const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts"); const repoRoot = path.join(import.meta.dirname, "..", ".."); - const oxlintBin = path.join( - repoRoot, - "node_modules", - ".pnpm", - "node_modules", - ".bin", - "oxlint", - ); + const oxlintBin = path.join(repoRoot, "node_modules", "oxlint", "bin", "oxlint"); const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts"); yield* fs.writeFileString( @@ -144,22 +139,15 @@ export const createOxlintRuleHarness = ( run, runAndExpectFailure, valid(name, source) { - test(name, (it) => { - it.effect("passes", () => run(source)); + it(name, async () => { + await runNodeEffect(run(source)); }); }, invalid(name, source, assertion) { - test(name, (it) => { - it.effect("reports the rule diagnostic", () => - runAndExpectFailure(source).pipe( - Effect.tap((output) => - Effect.sync(() => { - assert.match(output, new RegExp(diagnosticRuleName)); - assertion?.(output); - }), - ), - ), - ); + it(name, async () => { + const output = await runNodeEffect(runAndExpectFailure(source)); + assert.match(output, new RegExp(diagnosticRuleName)); + assertion?.(output); }); }, }; diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index d9efcd4263a..6b19604c80b 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -4,7 +4,7 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; +import { Fiber } from "effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index bf4e2c89392..f3bc72603ac 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + DEFAULT_MODEL, EnvironmentId, ProjectId, CommandId, @@ -139,7 +140,7 @@ describe("add project shared logic", () => { createWorkspaceRootIfMissing: true, defaultModelSelection: { instanceId: "codex", - model: "gpt-5.4", + model: DEFAULT_MODEL, }, }); }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 8124992ac59..29190ee2c05 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -382,13 +382,13 @@ export const WsProjectsReadFileRpc = Rpc.make(WS_METHODS.projectsReadFile, { export const WsProjectsGetDetailsRpc = Rpc.make(WS_METHODS.projectsGetDetails, { payload: ProjectDetailsInput, success: ProjectDetails, - error: ProjectDetailsError, + error: Schema.Union([ProjectDetailsError, EnvironmentAuthorizationError]), }); export const WsProjectsUpdateSettingsRpc = Rpc.make(WS_METHODS.projectsUpdateSettings, { payload: ProjectUpdateSettingsInput, success: ProjectSettings, - error: ProjectDetailsError, + error: Schema.Union([ProjectDetailsError, EnvironmentAuthorizationError]), }); export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { diff --git a/scripts/mobile-native-static-check.test.ts b/scripts/mobile-native-static-check.test.ts index 7393b4bd9c8..ad5361415db 100644 --- a/scripts/mobile-native-static-check.test.ts +++ b/scripts/mobile-native-static-check.test.ts @@ -12,6 +12,16 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { collectSources, runCommand } from "./mobile-native-static-check.ts"; +const assertPlatformError = (cause: unknown, reasonTag: string) => { + assert.isNotNull(cause); + assert.equal(typeof cause, "object"); + assert.equal((cause as { readonly _tag?: unknown })._tag, "PlatformError"); + assert.equal( + (cause as { readonly reason?: { readonly _tag?: unknown } }).reason?._tag, + reasonTag, + ); +}; + const processHandle = ( exitCode: Effect.Effect, ) => @@ -49,7 +59,7 @@ it.layer(NodeServices.layer)("mobile native source discovery", (it) => { assert.equal(error._tag, "NativeStaticCheckSourceDiscoveryError"); assert.equal(error.operation, "read-directory"); assert.equal(error.path, missingDirectory); - assert.instanceOf(error.cause, PlatformError.PlatformError); + assertPlatformError(error.cause, "NotFound"); assert.equal(error.message, "Native source discovery operation 'read-directory' failed."); }), ); diff --git a/scripts/mock-update-server.ts b/scripts/mock-update-server.ts index de50a6c0459..fc56b2c4a75 100644 --- a/scripts/mock-update-server.ts +++ b/scripts/mock-update-server.ts @@ -103,6 +103,22 @@ const isServableFile = (rootRealPath: string, filePath: string) => return yield* isWithinRoot(rootRealPath, filePath); }); +const contentTypeForExtension = (extension: string) => { + switch (extension) { + case ".json": + return "application/json"; + case ".yaml": + case ".yml": + return "text/yaml"; + case ".zip": + return "application/zip"; + case ".dmg": + return "application/x-apple-diskimage"; + default: + return "application/octet-stream"; + } +}; + export const makeMockUpdateRouteLayer = (rootRealPath: string) => { return HttpRouter.add( "*", @@ -124,7 +140,13 @@ export const makeMockUpdateRouteLayer = (rootRealPath: string) => { } yield* Effect.logInfo(`Serving file: ${filePath}`); - return yield* HttpServerResponse.file(filePath, { status: 200 }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const data = yield* fileSystem.readFile(filePath); + return HttpServerResponse.uint8Array(data, { + status: 200, + contentType: contentTypeForExtension(path.extname(filePath).toLowerCase()), + }); }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index dda1e7081be..293a1647863 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -5,7 +5,6 @@ import * as ConfigProvider from "effect/ConfigProvider"; 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 { readDesktopBaseVersion, @@ -15,6 +14,16 @@ import { writeNightlyReleaseOutput, } from "./resolve-nightly-release.ts"; +const assertPlatformError = (cause: unknown, reasonTag: string) => { + assert.isNotNull(cause); + assert.equal(typeof cause, "object"); + assert.equal((cause as { readonly _tag?: unknown })._tag, "PlatformError"); + assert.equal( + (cause as { readonly reason?: { readonly _tag?: unknown } }).reason?._tag, + reasonTag, + ); +}; + it("strips prerelease and build metadata when deriving the nightly base version", () => { assert.equal(resolveNightlyBaseVersion("0.0.17"), "0.0.17"); assert.equal(resolveNightlyBaseVersion("9.9.9-smoke.0"), "9.9.9"); @@ -92,7 +101,7 @@ it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { } assert.equal(error.operation, "read"); assert.equal(error.packageJsonPath, packageJsonPath); - assert.instanceOf(error.cause, PlatformError.PlatformError); + assertPlatformError(error.cause, "NotFound"); assert.notInclude(error.message, String((error.cause as Error).message)); }), ); diff --git a/scripts/update-release-package-versions.test.ts b/scripts/update-release-package-versions.test.ts index 01a27f3273b..863397d4a24 100644 --- a/scripts/update-release-package-versions.test.ts +++ b/scripts/update-release-package-versions.test.ts @@ -6,7 +6,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { Command, CliError } from "effect/unstable/cli"; import * as TestConsole from "effect/testing/TestConsole"; @@ -63,6 +62,16 @@ const readReleaseVersions = Effect.fn("readReleaseVersions")(function* (rootDir: return versions; }); +const assertPlatformError = (cause: unknown, reasonTag: string) => { + assert.isNotNull(cause); + assert.equal(typeof cause, "object"); + assert.equal((cause as { readonly _tag?: unknown })._tag, "PlatformError"); + assert.equal( + (cause as { readonly reason?: { readonly _tag?: unknown } }).reason?._tag, + reasonTag, + ); +}; + const captureLogs = (effect: Effect.Effect) => Effect.gen(function* () { const result = yield* effect; @@ -124,7 +133,7 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { assert.instanceOf(error, ReleasePackageManifestError); assert.equal(error.operation, "read"); assert.equal(error.filePath, filePath); - assert.instanceOf(error.cause, PlatformError.PlatformError); + assertPlatformError(error.cause, "NotFound"); assert.equal(error.message, `Failed to read release package manifest '${filePath}'.`); }), ); @@ -170,7 +179,7 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { assert.equal(error.operation, "write"); assert.equal(error.filePath, filePath); - assert.instanceOf(error.cause, PlatformError.PlatformError); + assertPlatformError(error.cause, "PermissionDenied"); assert.equal(error.message, `Failed to write release package manifest '${filePath}'.`); }), ); @@ -269,7 +278,7 @@ it.layer(ScriptTestLayer)("update-release-package-versions", (it) => { assert.instanceOf(error, ReleaseGitHubOutputWriteError); assert.equal(error.filePath, baseDir); - assert.instanceOf(error.cause, PlatformError.PlatformError); + assertPlatformError(error.cause, "BadResource"); assert.equal( error.message, `Failed to append release package version output to '${baseDir}'.`, From 9957075334548e17b3b1f051bce4456bb9893a16 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:48:06 +0530 Subject: [PATCH 03/23] Revert local-environment workarounds that break pnpm CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preserveSymlinks and the bun types entry break TS resolution under pnpm's symlinked node_modules, and node_modules/oxlint/bin/oxlint only exists in hoisted layouts — CI resolves the oxlint bin via the hidden pnpm store path. Co-Authored-By: Claude Fable 5 --- apps/server/tsconfig.json | 3 +- infra/relay/tsconfig.json | 1 - .../rules/namespace-node-imports.test.ts | 2 +- .../rules/no-global-process-runtime.test.ts | 2 +- .../rules/no-inline-schema-compile.test.ts | 2 +- .../no-manual-effect-runtime-in-tests.test.ts | 2 +- oxlint-plugin-t3code/test/utils.ts | 34 +++++++++++++------ 7 files changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 9e6eb5d99d3..63ce5a30c45 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,8 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "preserveSymlinks": true, - "types": ["node", "bun"], + "types": ["node"], "lib": ["ESNext", "esnext.disposable"] }, "include": ["src", "vite.config.ts", "scripts", "integration", "../../scripts/lib"] diff --git a/infra/relay/tsconfig.json b/infra/relay/tsconfig.json index b7f95c53885..6c0840a678b 100644 --- a/infra/relay/tsconfig.json +++ b/infra/relay/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "target": "ES2023", "lib": ["ES2023", "WebWorker"], - "preserveSymlinks": true, "types": ["@cloudflare/workers-types", "node"] }, "include": ["alchemy.run.ts", "scripts/**/*.ts", "src/**/*.ts"] diff --git a/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts index 5df539267f7..c097264ba8e 100644 --- a/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts +++ b/oxlint-plugin-t3code/rules/namespace-node-imports.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "vite-plus/test"; +import { assert, describe } from "@effect/vitest"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts index eb22485019b..dc9cf6979a7 100644 --- a/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts +++ b/oxlint-plugin-t3code/rules/no-global-process-runtime.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "vite-plus/test"; +import { assert, describe } from "@effect/vitest"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts b/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts index bc94b44fc58..1e95b2608a0 100644 --- a/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts +++ b/oxlint-plugin-t3code/rules/no-inline-schema-compile.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "vite-plus/test"; +import { assert, describe } from "@effect/vitest"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts index 680557a50f7..bd8c97f5fa4 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts @@ -1,4 +1,4 @@ -import { assert, describe } from "vite-plus/test"; +import { assert, describe } from "@effect/vitest"; import { createOxlintRuleHarness } from "../test/utils.ts"; diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index cc1b53bb0bc..eb91d32d7d4 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -8,7 +9,6 @@ import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { assert, it } from "vite-plus/test"; class OxlintFixtureFailure extends Data.TaggedError("OxlintFixtureFailure")<{ readonly exitCode: number; @@ -77,9 +77,6 @@ const spawnAndCollectOutput = Effect.fnUntraced(function* (command: ChildProcess return { exitCode, stdout, stderr }; }, Effect.scoped); -const runNodeEffect = (effect: Effect.Effect) => - Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); - export const createOxlintRuleHarness = ( ruleName: string, options: RuleHarnessOptions = {}, @@ -87,6 +84,7 @@ export const createOxlintRuleHarness = ( const [pluginName, shortRuleName] = ruleName.split("/"); const diagnosticRuleName = pluginName && shortRuleName ? `${pluginName}\\(${shortRuleName}\\)` : ruleName; + const test = it.layer(NodeServices.layer); const run: RuleHarness["run"] = Effect.fnUntraced(function* (source: string) { const fs = yield* FileSystem.FileSystem; @@ -95,7 +93,14 @@ export const createOxlintRuleHarness = ( const configPath = path.join(fixtureDir, ".oxlintrc.json"); const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts"); const repoRoot = path.join(import.meta.dirname, "..", ".."); - const oxlintBin = path.join(repoRoot, "node_modules", "oxlint", "bin", "oxlint"); + const oxlintBin = path.join( + repoRoot, + "node_modules", + ".pnpm", + "node_modules", + ".bin", + "oxlint", + ); const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts"); yield* fs.writeFileString( @@ -139,15 +144,22 @@ export const createOxlintRuleHarness = ( run, runAndExpectFailure, valid(name, source) { - it(name, async () => { - await runNodeEffect(run(source)); + test(name, (it) => { + it.effect("passes", () => run(source)); }); }, invalid(name, source, assertion) { - it(name, async () => { - const output = await runNodeEffect(runAndExpectFailure(source)); - assert.match(output, new RegExp(diagnosticRuleName)); - assertion?.(output); + test(name, (it) => { + it.effect("reports the rule diagnostic", () => + runAndExpectFailure(source).pipe( + Effect.tap((output) => + Effect.sync(() => { + assert.match(output, new RegExp(diagnosticRuleName)); + assertion?.(output); + }), + ), + ), + ); }); }, }; From f7df35f900722e8188a22b5288892b572a32b75d Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:54:38 +0530 Subject: [PATCH 04/23] Revert broken drive-by changes to mobile keyboard list, imageMime, and MCP test - keyboardLegendList.tsx imported KeyboardAvoidingLegendList, which @legendapp/list/keyboard does not export - the imageMime getExtension wrapper called the method unbound, tripping private-field access in the mime instance - the MCP DELETE test expectations no longer matched actual server behavior (400 for missing session, 204 for terminate) Co-Authored-By: Claude Fable 5 --- .../features/threads/ThreadDetailScreen.tsx | 2 +- .../src/features/threads/ThreadFeed.tsx | 6 +- .../features/threads/keyboardLegendList.tsx | 118 ------------------ apps/server/src/imageMime.ts | 12 +- apps/server/src/mcp/McpHttpServer.test.ts | 15 ++- 5 files changed, 12 insertions(+), 141 deletions(-) delete mode 100644 apps/mobile/src/features/threads/keyboardLegendList.tsx diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5b520bd7f7d..62d1bce1157 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,5 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; import type { ApprovalRequestId, @@ -36,7 +37,6 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; -import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "./keyboardLegendList"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fd3233a1f31..a82e7c49ccd 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,4 +1,5 @@ import * as Haptics from "expo-haptics"; +import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; @@ -53,7 +54,6 @@ import { type ReviewInlineComment, } from "../review/reviewCommentSelection"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; -import { KeyboardAwareLegendList } from "./keyboardLegendList"; import { buildNativeReviewDiffData, createNativeReviewDiffTheme, @@ -1477,8 +1477,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { data={presentedFeed} extraData={listAppearanceData} renderItem={renderItem} - keyExtractor={(entry: ThreadFeedEntry) => entry.id} - getItemType={(entry: ThreadFeedEntry) => + keyExtractor={(entry) => entry.id} + getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } keyboardShouldPersistTaps="always" diff --git a/apps/mobile/src/features/threads/keyboardLegendList.tsx b/apps/mobile/src/features/threads/keyboardLegendList.tsx deleted file mode 100644 index 85bd9fd3261..00000000000 --- a/apps/mobile/src/features/threads/keyboardLegendList.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; -import type { AnimatedLegendListProps } from "@legendapp/list/reanimated"; -import type { LegendListRef } from "@legendapp/list/react-native"; -import type { ChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; -import * as React from "react"; -import { useCallback, useEffect, useRef, type ForwardedRef } from "react"; -import { Keyboard, type Insets, type LayoutChangeEvent } from "react-native"; -import { useSharedValue, type SharedValue } from "react-native-reanimated"; - -interface KeyboardAwareLegendListProps extends Omit< - AnimatedLegendListProps, - "onScroll" -> { - readonly anchoredEndSpace?: ChatListAnchoredEndSpace; - readonly contentInsetEndAdjustment?: SharedValue; - readonly freeze?: SharedValue; - readonly keyboardLiftBehavior?: "always" | "whenAtEnd" | "never"; - readonly onScroll?: AnimatedLegendListProps["onScroll"]; - readonly contentInset?: Insets; - readonly safeAreaInsetBottom?: number; -} - -function assignRef(ref: ForwardedRef, value: LegendListRef | null) { - if (typeof ref === "function") { - ref(value); - return; - } - if (ref) { - ref.current = value; - } -} - -function KeyboardAwareLegendListInner( - props: KeyboardAwareLegendListProps, - forwardedRef: ForwardedRef, -) { - const { - anchoredEndSpace, - contentInsetEndAdjustment: _contentInsetEndAdjustment, - freeze: _freeze, - keyboardLiftBehavior: _keyboardLiftBehavior, - ...listProps - } = props; - const listRef = useRef(null); - const setListRef = useCallback( - (value: LegendListRef | null) => { - listRef.current = value; - assignRef(forwardedRef, value); - }, - [forwardedRef], - ); - - useEffect(() => { - if (!anchoredEndSpace) { - return; - } - void listRef.current - ?.scrollToIndex({ - index: anchoredEndSpace.anchorIndex, - viewOffset: anchoredEndSpace.anchorOffset, - viewPosition: 0, - animated: true, - }) - .catch(() => undefined); - }, [anchoredEndSpace]); - - return ; -} - -export const KeyboardAwareLegendList = React.forwardRef(KeyboardAwareLegendListInner) as ( - props: KeyboardAwareLegendListProps & React.RefAttributes, -) => React.ReactElement | null; - -export function useKeyboardChatComposerInset( - listRef: React.RefObject, - _composerOverlayRef: React.RefObject, - estimatedOverlayHeight: number, -) { - const contentInsetEndAdjustment = useSharedValue(estimatedOverlayHeight); - - useEffect(() => { - contentInsetEndAdjustment.set(estimatedOverlayHeight); - listRef.current?.reportContentInset({ bottom: estimatedOverlayHeight }); - }, [contentInsetEndAdjustment, estimatedOverlayHeight, listRef]); - - const onComposerLayout = useCallback( - (event: LayoutChangeEvent) => { - const nextHeight = Math.max(estimatedOverlayHeight, event.nativeEvent.layout.height); - contentInsetEndAdjustment.set(nextHeight); - listRef.current?.reportContentInset({ bottom: nextHeight }); - }, - [contentInsetEndAdjustment, estimatedOverlayHeight, listRef], - ); - - return { contentInsetEndAdjustment, onComposerLayout }; -} - -export function useKeyboardScrollToEnd(props: { - readonly listRef: React.RefObject; -}) { - const freeze = useSharedValue(false); - const scrollMessageToEnd = useCallback( - async (options: { readonly animated?: boolean; readonly closeKeyboard?: boolean } = {}) => { - freeze.set(true); - if (options.closeKeyboard ?? true) { - Keyboard.dismiss(); - } - try { - await props.listRef.current?.scrollToEnd({ animated: options.animated ?? true }); - } finally { - requestAnimationFrame(() => freeze.set(false)); - } - }, - [freeze, props.listRef], - ); - - return { freeze, scrollMessageToEnd }; -} diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 46dd242d0b8..c8761285abe 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -1,15 +1,5 @@ import Mime from "@effect/platform-node/Mime"; -type MimeModule = { - readonly getExtension?: (mimeType: string) => string | false | null; - readonly default?: { - readonly getExtension?: (mimeType: string) => string | false | null; - }; -}; - -const getMimeExtension = (mimeType: string) => - ((Mime as MimeModule).getExtension ?? (Mime as MimeModule).default?.getExtension)?.(mimeType); - export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/avif": ".avif", "image/bmp": ".bmp", @@ -76,7 +66,7 @@ export function inferImageExtension(input: { mimeType: string; fileName?: string return fromMime; } - const fromMimeExtension = getMimeExtension(input.mimeType); + const fromMimeExtension = Mime.getExtension(input.mimeType); if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { return fromMimeExtension; } diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index e768b1a48a2..ca3341be7f3 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -97,7 +97,7 @@ it.effect("returns bounded structural preview snapshot failures", () => ).pipe(Effect.provide(TestLayer)), ); -it.effect("returns not found for unsupported HTTP MCP session DELETE requests", () => +it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { const serverLayer = McpServer.layerHttp({ @@ -120,11 +120,10 @@ it.effect("returns not found for unsupported HTTP MCP session DELETE requests", }); const sessionId = initializeResponse.headers["mcp-session-id"]; expect(initializeResponse.status).toBe(200); - expect(typeof sessionId).toBe("string"); - const sessionHeader = sessionId as string; + expect(sessionId).not.toBeNull(); const missingSessionResponse = yield* httpClient.del("/mcp"); - expect(missingSessionResponse.status).toBe(404); + expect(missingSessionResponse.status).toBe(400); const unknownSessionResponse = yield* httpClient.del("/mcp", { headers: { "mcp-session-id": "unknown-session" }, @@ -132,21 +131,21 @@ it.effect("returns not found for unsupported HTTP MCP session DELETE requests", expect(unknownSessionResponse.status).toBe(404); const terminateResponse = yield* httpClient.del("/mcp", { - headers: { "mcp-session-id": sessionHeader }, + headers: { "mcp-session-id": sessionId! }, }); - expect(terminateResponse.status).toBe(404); + expect(terminateResponse.status).toBe(204); const reusedSessionResponse = yield* httpClient.post("/mcp", { headers: { accept: "application/json, text/event-stream", - "mcp-session-id": sessionHeader, + "mcp-session-id": sessionId!, }, body: HttpBody.text( `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`, "application/json", ), }); - expect(reusedSessionResponse.status).toBe(200); + expect(reusedSessionResponse.status).toBe(404); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); From 2070e3a503e3b2edf75e1b42994a8089f51d253d Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:05:44 +0530 Subject: [PATCH 05/23] Retrigger CI after runner cache timeout Co-Authored-By: Claude Fable 5 From c17820e59d2df4947cb2c3153595da43d2e5851d Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:13:59 +0530 Subject: [PATCH 06/23] Address Macroscope review feedback Correctness: - Fall back to remote detection when settings reads fail during provider detection instead of failing the whole resolve - Look up project overrides by repository root for worktree cwds - Fall back to remoteUrl when an override webUrl does not parse - Prefer remotes with a known provider in pickPrimaryRemote - Refresh project-scoped VCS status cache entries on bare-cwd refreshes - Upsert the new project script keybinding before removing old ones - Seed project default model when a draft is rebound to another project Conventions: - Inline the extracted *Shape service interfaces back into the Context.Service declarations - Restore the effect/Fiber subpath import and streaming asset responses Co-Authored-By: Claude Fable 5 --- apps/server/src/http.ts | 49 ++--------- apps/server/src/serverSettings.ts | 44 +++++----- .../src/sourceControl/RemoteOverride.ts | 4 +- .../SourceControlProviderRegistry.ts | 51 ++++++----- apps/server/src/vcs/VcsStatusBroadcaster.ts | 87 +++++++++++++------ apps/server/src/ws.ts | 5 +- apps/web/src/hooks/useHandleNewThread.ts | 12 +++ apps/web/src/lib/projectScriptKeybindings.ts | 4 +- .../src/connection/supervisor.ts | 2 +- 9 files changed, 132 insertions(+), 126 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 2ae2c1d9806..fc7a9ef13a2 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,3 +1,4 @@ +import Mime from "@effect/platform-node/Mime"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -45,38 +46,6 @@ const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; -const contentTypeForExtension = (extension: string) => { - switch (extension.toLowerCase()) { - case ".css": - return "text/css; charset=utf-8"; - case ".html": - return "text/html; charset=utf-8"; - case ".ico": - return "image/x-icon"; - case ".jpeg": - case ".jpg": - return "image/jpeg"; - case ".js": - case ".mjs": - return "text/javascript; charset=utf-8"; - case ".json": - case ".map": - return "application/json"; - case ".png": - return "image/png"; - case ".svg": - return "image/svg+xml"; - case ".txt": - return "text/plain; charset=utf-8"; - case ".wasm": - return "application/wasm"; - case ".webp": - return "image/webp"; - default: - return "application/octet-stream"; - } -}; - export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -238,21 +207,15 @@ export const assetRouteLayer = HttpRouter.add( }); } - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const data = yield* fileSystem.readFile(asset.path).pipe(Effect.orElseSucceed(() => null)); - if (!data) { - return HttpServerResponse.text("Internal Server Error", { status: 500 }); - } - - return HttpServerResponse.uint8Array(data, { + return yield* HttpServerResponse.file(asset.path, { status: 200, - contentType: contentTypeForExtension(path.extname(asset.path)), headers: { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", }, - }); + }).pipe( + Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), + ); }), ); @@ -331,7 +294,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( }); } - const contentType = contentTypeForExtension(path.extname(filePath)); + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; const data = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); if (!data) { return HttpServerResponse.text("Internal Server Error", { status: 500 }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index bdab5f17bb6..8ab703db1fa 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -117,34 +117,32 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS return { ...settings, providerInstances }; } -export interface ServerSettingsShape { - /** Start the settings runtime and attach file watching. */ - readonly start: Effect.Effect; - - /** Await settings runtime readiness. */ - readonly ready: Effect.Effect; +export class ServerSettingsService extends Context.Service< + ServerSettingsService, + { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; - /** Read the current settings. */ - readonly getSettings: Effect.Effect; + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; - /** Patch settings and persist. Returns the new full settings object. */ - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => Effect.Effect; + /** Read the current settings. */ + readonly getSettings: Effect.Effect; - /** Update one project's settings from the latest persisted snapshot. */ - readonly updateProjectSettings: ( - projectId: ProjectId, - patch: ProjectSettingsPatch, - ) => Effect.Effect; + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; - /** Stream of settings change events. */ - readonly streamChanges: Stream.Stream; -} + /** Update one project's settings from the latest persisted snapshot. */ + readonly updateProjectSettings: ( + projectId: ProjectId, + patch: ProjectSettingsPatch, + ) => Effect.Effect; -export class ServerSettingsService extends Context.Service< - ServerSettingsService, - ServerSettingsShape + /** Stream of settings change events. */ + readonly streamChanges: Stream.Stream; + } >()("t3/serverSettings/ServerSettingsService") { /** @deprecated Import and use `layerTest` from this module. */ static readonly layerTest = (overrides: DeepPartial = {}) => layerTest(overrides); diff --git a/apps/server/src/sourceControl/RemoteOverride.ts b/apps/server/src/sourceControl/RemoteOverride.ts index b5476d9c47a..ea4a83458b8 100644 --- a/apps/server/src/sourceControl/RemoteOverride.ts +++ b/apps/server/src/sourceControl/RemoteOverride.ts @@ -50,9 +50,7 @@ export function providerName(kind: SourceControlProviderKind, baseUrl: string | export function providerInfoFromOverride( override: ProjectRemoteOverride, ): SourceControlProviderInfo | null { - const baseUrl = override.webUrl - ? parseBaseUrl(override.webUrl) - : parseBaseUrl(override.remoteUrl); + const baseUrl = parseBaseUrl(override.webUrl ?? "") ?? parseBaseUrl(override.remoteUrl); if (!baseUrl) { return null; } diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 0638a40d30b..fd380889ea2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -48,28 +48,26 @@ export interface SourceControlProviderHandle { readonly contextSource: "override" | "detected" | null; } -export interface SourceControlProviderRegistryShape { - readonly get: ( - kind: SourceControlProviderKind, - ) => Effect.Effect< - SourceControlProvider.SourceControlProvider["Service"], - SourceControlProviderError - >; - readonly resolveHandle: ( - input: SourceControlProviderResolveInput, - ) => Effect.Effect; - readonly resolve: ( - input: SourceControlProviderResolveInput, - ) => Effect.Effect< - SourceControlProvider.SourceControlProvider["Service"], - SourceControlProviderError - >; - readonly discover: Effect.Effect>; -} - export class SourceControlProviderRegistry extends Context.Service< SourceControlProviderRegistry, - SourceControlProviderRegistryShape + { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly resolveHandle: ( + input: SourceControlProviderResolveInput, + ) => Effect.Effect; + readonly resolve: ( + input: SourceControlProviderResolveInput, + ) => Effect.Effect< + SourceControlProvider.SourceControlProvider["Service"], + SourceControlProviderError + >; + readonly discover: Effect.Effect>; + } >()("t3/sourceControl/SourceControlProviderRegistry") {} function unsupportedProvider( @@ -248,9 +246,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit if (projectId) { const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)), + Effect.catch(() => Effect.succeed(null)), ); - const override = settings.projectSettings[projectId]?.remoteOverride ?? null; + const override = settings?.projectSettings[projectId]?.remoteOverride ?? null; const overrideContext = override ? providerContextFromOverride(override) : null; if (overrideContext) { return { context: overrideContext, source: "override" as const }; @@ -267,7 +265,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit .getActiveProjectByWorkspaceRoot(cwd) .pipe( Effect.flatMap((project) => - Option.isSome(project) || repository === null || repository.rootPath === cwd + Option.isSome(project) || repository === null ? Effect.succeed(project) : projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(repository.rootPath), ), @@ -275,9 +273,10 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); if (!projectId && Option.isSome(projectOption)) { const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)), + Effect.catch(() => Effect.succeed(null)), ); - const override = settings.projectSettings[projectOption.value.id]?.remoteOverride ?? null; + const override = + settings?.projectSettings[projectOption.value.id]?.remoteOverride ?? null; const overrideContext = override ? providerContextFromOverride(override) : null; if (overrideContext) { return { context: overrideContext, source: "override" as const }; @@ -311,7 +310,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), }); - const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => Cache.get(providerContextCache, providerDetectionCacheKey(input)).pipe( Effect.map(({ context, source }) => { const kind = context?.provider.kind ?? "unknown"; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 6e369d0d301..d93ab8de22a 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -57,25 +57,23 @@ interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } -export interface VcsStatusBroadcasterShape { - 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; -} - export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, - VcsStatusBroadcasterShape + { + 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 { @@ -356,7 +354,7 @@ export const make = Effect.gen(function* () { return yield* loadRemoteStatus(input); }); - const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( + const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( "VcsStatusBroadcaster.getStatus", )(function* (input) { const normalizedInput = yield* normalizeStatusInput(input); @@ -375,7 +373,7 @@ export const make = Effect.gen(function* () { }, ); - const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( + const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); @@ -393,7 +391,36 @@ export const make = Effect.gen(function* () { return yield* updateCachedRemoteStatus(statusCacheKey(input), remote, { publish: true }); }); - const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( + const cachedProjectInputsForCwd = Effect.fn("VcsStatusBroadcaster.cachedProjectInputsForCwd")( + function* (cwd: string) { + const cache = yield* Ref.get(cacheRef); + const inputs: Array = []; + for (const key of cache.keys()) { + 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 === cwd && projectId) { + // Keys are only built from validated inputs, so the round-trip preserves the brand. + inputs.push({ cwd, projectId: projectId as VcsStatusInput["projectId"] }); + } + } + return inputs; + }, + ); + + 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* (input) { const normalizedInput = yield* normalizeStatusInput(refreshInputToStatusInput(input)); @@ -404,13 +431,17 @@ export const make = Effect.gen(function* () { ], { concurrency: "unbounded", discard: true }, ); - const [local, remote] = yield* Effect.all( - [workflow.localStatus(normalizedInput), workflow.remoteStatus(normalizedInput)], - { concurrency: "unbounded" }, - ); - return yield* updateCachedStatus(statusCacheKey(normalizedInput), local, remote, { - publish: true, - }); + const result = yield* refreshStatusForInput(normalizedInput); + if (normalizedInput.projectId === undefined) { + // A bare-cwd refresh must also update project-scoped cache entries for the same + // repository, or subscribers keyed by projectId keep serving stale status. + const projectInputs = yield* cachedProjectInputsForCwd(normalizedInput.cwd); + yield* Effect.forEach(projectInputs, refreshStatusForInput, { + concurrency: "unbounded", + discard: true, + }); + } + return result; }); const makeRemoteRefreshLoop = ( @@ -538,7 +569,7 @@ export const make = Effect.gen(function* () { } }); - const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { const normalizedInput = yield* normalizeStatusInput(input); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f8e5777007..eb51a88a3d6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -398,9 +398,12 @@ function effectiveRemoteFromDetected( } 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.find((remote) => remote.provider !== null && remote.provider.kind !== "unknown") ?? remotes[0] ?? null ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 8ba296e10c2..9e353da6942 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -120,6 +120,12 @@ export function useNewThreadHandler() { threadId: reusableStoredDraftThread.threadId, }, ); + if ( + reusableStoredDraftThread.projectId !== projectRef.projectId || + reusableStoredDraftThread.environmentId !== projectRef.environmentId + ) { + seedNewDraftModelState(reusableStoredDraftThread.draftId); + } if ( currentRouteTarget?.kind === "draft" && currentRouteTarget.draftId === reusableStoredDraftThread.draftId @@ -162,6 +168,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(); } diff --git a/apps/web/src/lib/projectScriptKeybindings.ts b/apps/web/src/lib/projectScriptKeybindings.ts index 6021cffb91e..3ef17c31a18 100644 --- a/apps/web/src/lib/projectScriptKeybindings.ts +++ b/apps/web/src/lib/projectScriptKeybindings.ts @@ -114,10 +114,12 @@ export async function syncProjectScriptKeybinding(input: { 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); } } - await input.server.upsertKeybinding(nextRule); } diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 6b19604c80b..d9efcd4263a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -4,7 +4,7 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import { Fiber } from "effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; From 86d9eeb7e9807e50a6c3fd8a3789cf3e9c7904cf Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:54:16 +0530 Subject: [PATCH 07/23] Address second-round review feedback - Thread projectId through PR operations end to end (contracts, GitManager helper chain, vcs action manager, web callers) so project remote overrides apply to resolving, creating, and checking out pull requests - Invalidate the provider-detection cache on settings changes so a saved remote override takes effect immediately instead of after the cache TTL - Refresh project-scoped cache entries in refreshLocalStatus, matching refreshStatus - Fall back to the sticky model selection when a project has no default model instead of resetting drafts to the canonical default - Clear the project default model when its provider instance is disabled - Report keybinding-sync failures without failing the script save, which duplicated the script on retry - Render no providers (rather than the primary environment's) while a non-primary project's server config loads - Inline the construct-only providerDetectionError helper Co-Authored-By: Claude Fable 5 --- apps/server/src/git/GitManager.ts | 70 ++++++++++--------- .../SourceControlProviderRegistry.ts | 41 +++++++---- apps/server/src/vcs/VcsStatusBroadcaster.ts | 52 +++++++++----- apps/web/src/components/ChatView.tsx | 1 + .../components/PullRequestThreadDialog.tsx | 9 ++- apps/web/src/hooks/useHandleNewThread.ts | 18 +++-- .../projects.$environmentId.$projectId.tsx | 28 ++++++-- apps/web/src/state/server.ts | 2 +- apps/web/src/state/sourceControlActions.ts | 25 ++++++- .../src/state/vcsAction.test.ts | 2 +- .../client-runtime/src/state/vcsAction.ts | 13 ++-- packages/contracts/src/git.ts | 3 + 12 files changed, 179 insertions(+), 85 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 63d46a12744..e777db3cc56 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -571,10 +571,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); @@ -596,7 +597,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, }); @@ -627,14 +628,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, @@ -644,10 +645,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) { @@ -659,7 +661,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, }); @@ -691,15 +693,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, }) @@ -707,7 +709,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, @@ -974,7 +976,7 @@ export const make = Effect.gen(function* () { }); const findOpenPr = Effect.fn("findOpenPr")(function* ( - cwd: string, + target: VcsStatusInput, headContext: Pick< BranchHeadContext, | "headBranch" @@ -984,8 +986,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", @@ -1046,10 +1049,11 @@ export const make = Effect.gen(function* () { }); 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")), ); @@ -1094,7 +1098,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), ); } @@ -1140,11 +1144,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; @@ -1157,7 +1162,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), ); @@ -1351,11 +1356,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; @@ -1379,7 +1385,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, @@ -1391,7 +1397,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", @@ -1440,7 +1446,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, @@ -1501,7 +1507,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), @@ -1537,21 +1543,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), @@ -1570,7 +1576,7 @@ export const make = Effect.gen(function* () { ) { const details = yield* gitCore.statusDetails(worktreePath); yield* configurePullRequestHeadUpstream( - worktreePath, + { cwd: worktreePath, projectId: input.projectId }, { ...pullRequest, ...toPullRequestHeadRemoteInfo(pullRequestSummary), @@ -1637,7 +1643,7 @@ export const make = Effect.gen(function* () { } yield* materializePullRequestHeadBranch( - input.cwd, + input, pullRequestWithRemoteInfo, localPullRequestBranch, ); @@ -1821,7 +1827,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")), ) @@ -1868,12 +1874,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/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fd380889ea2..39f753505e2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -5,6 +5,7 @@ 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, @@ -132,16 +133,6 @@ function unsupportedProvider( }); } -function providerDetectionError(operation: string, cwd: string, cause: unknown) { - return new SourceControlProviderError({ - provider: "unknown", - operation, - cwd, - detail: "Failed to detect source control provider.", - cause, - }); -} - function providerDetectionCacheKey(input: SourceControlProviderResolveInput) { return `${input.cwd}${PROVIDER_DETECTION_CACHE_KEY_SEPARATOR}${input.projectId ?? ""}`; } @@ -257,7 +248,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const handle = yield* vcsRegistry .resolve({ cwd }) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + .pipe(Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + )); const repository = yield* handle.driver .detectRepository(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -285,7 +285,16 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const remotes = yield* handle.driver .listRemotes(cwd) - .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + .pipe(Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "unknown", + operation: "detectProvider", + cwd, + detail: "Failed to detect source control provider.", + cause: error, + }), + )); const context = selectProviderContext(remotes.remotes); const refinedContext = yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ @@ -310,6 +319,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit 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, providerDetectionCacheKey(input)).pipe( Effect.map(({ context, source }) => { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d93ab8de22a..0776d03e5a0 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -218,6 +218,23 @@ export const make = Effect.gen(function* () { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(key) ?? null)); }); + const cachedProjectInputsForCwd = Effect.fn("VcsStatusBroadcaster.cachedProjectInputsForCwd")( + function* (cwd: string) { + const cache = yield* Ref.get(cacheRef); + const inputs: Array = []; + for (const key of cache.keys()) { + 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 === cwd && projectId) { + // Keys are only built from validated inputs, so the round-trip preserves the brand. + inputs.push({ cwd, projectId: projectId as VcsStatusInput["projectId"] }); + } + } + return inputs; + }, + ); + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (key: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { @@ -377,7 +394,23 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusForInput({ 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 projectInputs = yield* cachedProjectInputsForCwd(cwd); + yield* Effect.forEach( + projectInputs, + (projectInput) => + workflow + .localStatus(projectInput) + .pipe( + Effect.flatMap((local) => + updateCachedLocalStatus(statusCacheKey(projectInput), local, { publish: true }), + ), + ), + { concurrency: "unbounded", discard: true }, + ); + return result; }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( @@ -391,23 +424,6 @@ export const make = Effect.gen(function* () { return yield* updateCachedRemoteStatus(statusCacheKey(input), remote, { publish: true }); }); - const cachedProjectInputsForCwd = Effect.fn("VcsStatusBroadcaster.cachedProjectInputsForCwd")( - function* (cwd: string) { - const cache = yield* Ref.get(cacheRef); - const inputs: Array = []; - for (const key of cache.keys()) { - 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 === cwd && projectId) { - // Keys are only built from validated inputs, so the round-trip preserves the brand. - inputs.push({ cwd, projectId: projectId as VcsStatusInput["projectId"] }); - } - } - return inputs; - }, - ); - const refreshStatusForInput = Effect.fn("VcsStatusBroadcaster.refreshStatusForInput")(function* ( input: VcsStatusInput, ) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 68ab1586892..d553bbc2f9c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5301,6 +5301,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/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/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 9e353da6942..cb30696aea8 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -6,10 +6,8 @@ import { import { DEFAULT_RUNTIME_MODE, DEFAULT_SERVER_SETTINGS, - type ModelSelection, type ScopedProjectRef, } from "@t3tools/contracts"; -import { createDefaultModelSelection } from "@t3tools/shared/model"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -52,6 +50,7 @@ export function useNewThreadHandler() { }, ): Promise => { const { + applyStickyState, getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, @@ -85,12 +84,17 @@ export function useNewThreadHandler() { if (storedDraftThreadRef && reusableStoredDraftThread === null) { markPromotedDraftThreadByRef(storedDraftThreadRef); } - const projectDefaultModelSelection: ModelSelection = - project?.defaultModelSelection ?? createDefaultModelSelection(); + const projectDefaultModelSelection = project?.defaultModelSelection ?? null; const seedNewDraftModelState = (draftId: Parameters[0]) => { - setModelSelection(draftId, projectDefaultModelSelection, { - preserveExistingOptions: false, - }); + if (projectDefaultModelSelection) { + setModelSelection(draftId, projectDefaultModelSelection, { + preserveExistingOptions: false, + }); + return; + } + // Without a per-project default, keep the user's sticky selection + // instead of resetting to the canonical default model. + applyStickyState(draftId); }; const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index a37edff8995..95e4ccad5f1 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -79,7 +79,11 @@ import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; -import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { + EMPTY_SERVER_PROVIDERS, + primaryServerKeybindingsAtom, + primaryServerProvidersAtom, +} from "../state/server"; const PROVIDER_LABELS: Record = { github: "GitHub", @@ -202,7 +206,10 @@ function ProjectRouteView() { ); const serverProviders = project?.environmentId && project.environmentId !== primaryEnvironmentId - ? (projectServerConfig?.providers ?? primaryProviders) + ? // 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( () => @@ -492,9 +499,16 @@ function ProjectRouteView() { void commitProjectSettings({ disabledProviderInstanceIds: nextDisabledProviderInstanceIds, }); + if (!allowed && defaultModelSelection?.instanceId === instanceId) { + // The default model pointed at the instance that was just disabled; + // clear it so new threads fall back to an allowed provider. + commitDefaultModelSelection(null); + } }, [ + commitDefaultModelSelection, commitProjectSettings, + defaultModelSelection, disabledProviderInstanceIds, globallyEnabledProviderInstanceEntries, projectProviderInstanceEntries.length, @@ -548,10 +562,16 @@ function ProjectRouteView() { server: readLocalApi()?.server, }), ); + refreshProjectDetails(); if (keybindingResult._tag === "Failure") { - return keybindingResult; + // 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), + ); } - refreshProjectDetails(); return updateResult; }; 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 34e96bcefd2..ccf220cbc48 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -336,6 +336,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 } : {}), @@ -355,9 +356,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; + readonly reference: string; +}) { + return { + cwd: target.cwd, + ...(target.projectId ? { projectId: target.projectId } : {}), + reference: target.reference, + }; +} + export function readCachedPullRequestResolution( target: PullRequestResolutionTarget, ): GitResolvePullRequestResult | null { @@ -369,7 +383,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, + }), }), ), ), @@ -381,10 +399,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/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 7e618535ad8..6329d755959 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -210,7 +210,7 @@ describe("vcsActionState", () => { environmentId, cwd, }), - ).toBe(JSON.stringify([environmentId, cwd])); + ).toBe(JSON.stringify([environmentId, cwd, null])); expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); expect( getVcsActionTargetKey({ diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 8ae3219a243..714d58b25c5 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -6,6 +6,8 @@ import { type GitRunStackedActionInput, type GitRunStackedActionResult, GitStackedAction, + ProjectId, + type ProjectId as ProjectIdType, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -55,11 +57,13 @@ export interface VcsActionState { export interface VcsActionTarget { readonly environmentId: EnvironmentIdType | null; readonly cwd: string | null; + readonly projectId?: ProjectIdType | null; } export interface ResolvedVcsActionTarget { readonly environmentId: EnvironmentIdType; readonly cwd: string; + readonly projectId: ProjectIdType | null; } export interface BeginVcsActionInput { @@ -158,7 +162,7 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze({ const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); let nextLocalActionId = 0; const decodeVcsActionTargetKey = Schema.decodeUnknownSync( - Schema.Tuple([EnvironmentId, Schema.String]), + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(ProjectId)]), ); export const vcsActionStateAtom = Atom.family((key: string) => { @@ -177,13 +181,13 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; } - return JSON.stringify([target.environmentId, target.cwd]); + return JSON.stringify([target.environmentId, target.cwd, target.projectId ?? null]); } export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { try { - const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key)); - return { environmentId, cwd }; + const [environmentId, cwd, projectId] = decodeVcsActionTargetKey(JSON.parse(key)); + return { environmentId, cwd, projectId }; } catch (cause) { throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause }); } @@ -459,6 +463,7 @@ export function createVcsActionManager( const rpcInput: GitRunStackedActionInput = { actionId: transportActionId, cwd: target.cwd, + ...(target.projectId ? { projectId: target.projectId } : {}), action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 93ed737c37b..8802e4e8c35 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -119,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), @@ -151,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), From 7f0689ea3758f3e8803fd5ec0da670260ee36afa Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:02:27 +0530 Subject: [PATCH 08/23] Fix formatting in SourceControlProviderRegistry Co-Authored-By: Claude Fable 5 --- .../SourceControlProviderRegistry.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 39f753505e2..4f87a6e2d8e 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -246,9 +246,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit } } - const handle = yield* vcsRegistry - .resolve({ cwd }) - .pipe(Effect.mapError( + const handle = yield* vcsRegistry.resolve({ cwd }).pipe( + Effect.mapError( (error) => new SourceControlProviderError({ provider: "unknown", @@ -257,7 +256,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit detail: "Failed to detect source control provider.", cause: error, }), - )); + ), + ); const repository = yield* handle.driver .detectRepository(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -283,9 +283,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit } } - const remotes = yield* handle.driver - .listRemotes(cwd) - .pipe(Effect.mapError( + const remotes = yield* handle.driver.listRemotes(cwd).pipe( + Effect.mapError( (error) => new SourceControlProviderError({ provider: "unknown", @@ -294,7 +293,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit detail: "Failed to detect source control provider.", cause: error, }), - )); + ), + ); const context = selectProviderContext(remotes.remotes); const refinedContext = yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ From 80837294b02f39e0e2f3e0d6552a6077d356a853 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:18:57 +0530 Subject: [PATCH 09/23] Address third-round review feedback - Key vcs action commands by environment and cwd again so all operations on a worktree share one serial lock; the project context rides along from the requesting target instead of forking the key - Make ResolvedVcsActionTarget.projectId optional to match - Log a warning when a settings read failure skips the override lookup - Seed the canonical default model when neither a project default nor a sticky selection exists - Resolve project script keybindings from the project's own environment config instead of always using the primary environment - Roll back the optimistic settings draft when a commit RPC fails so the UI does not present rejected edits as saved Co-Authored-By: Claude Fable 5 --- .../SourceControlProviderRegistry.ts | 21 +++++++++++++------ apps/web/src/hooks/useHandleNewThread.ts | 12 +++++++++++ .../projects.$environmentId.$projectId.tsx | 15 ++++++++++++- .../src/state/vcsAction.test.ts | 2 +- .../client-runtime/src/state/vcsAction.ts | 18 +++++++++------- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 4f87a6e2d8e..4da5c2d556c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -235,10 +235,21 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit 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 settingsForOverrideLookup = serverSettings.getSettings.pipe( + Effect.tapError((error) => + Effect.logWarning("project remote override lookup skipped: settings unavailable", { + cause: error, + }), + ), + Effect.catch(() => Effect.succeed(null)), + ); + if (projectId) { - const settings = yield* serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), - ); + const settings = yield* settingsForOverrideLookup; const override = settings?.projectSettings[projectId]?.remoteOverride ?? null; const overrideContext = override ? providerContextFromOverride(override) : null; if (overrideContext) { @@ -272,9 +283,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit Effect.catch(() => Effect.succeed(Option.none())), ); if (!projectId && Option.isSome(projectOption)) { - const settings = yield* serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), - ); + const settings = yield* settingsForOverrideLookup; const override = settings?.projectSettings[projectOption.value.id]?.remoteOverride ?? null; const overrideContext = override ? providerContextFromOverride(override) : null; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index cb30696aea8..aafe70006e5 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -8,6 +8,7 @@ import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, } from "@t3tools/contracts"; +import { createDefaultModelSelection } from "@t3tools/shared/model"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -94,6 +95,17 @@ export function useNewThreadHandler() { } // 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(), { + preserveExistingOptions: false, + }); + return; + } applyStickyState(draftId); }; const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index 95e4ccad5f1..c27b6cab882 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -1,6 +1,7 @@ import { ArrowLeftIcon, PlusIcon, RefreshCwIcon, 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 { @@ -183,7 +184,7 @@ function ProjectRouteView() { ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const primaryProviders = useAtomValue(primaryServerProvidersAtom); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const primaryKeybindings = useAtomValue(primaryServerKeybindingsAtom); const primarySettings = usePrimarySettings(); const serverConfigs = useServerConfigs(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); @@ -194,6 +195,10 @@ function ProjectRouteView() { 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 @@ -336,6 +341,10 @@ function ProjectRouteView() { }, }); if (result._tag === "Failure") { + // Drop the optimistic draft so the UI falls back to the persisted + // values instead of showing the rejected edit as saved. + setDraft(null); + refreshProjectDetails(); showProjectSettingsError( "Failed to update project settings", squashAtomCommandFailure(result), @@ -361,6 +370,10 @@ function ProjectRouteView() { }, }); if (result._tag === "Failure") { + // Drop the optimistic draft so the UI falls back to the persisted + // values instead of showing the rejected edit as saved. + setDraft(null); + refreshProjectDetails(); showProjectSettingsError( "Failed to update project settings", squashAtomCommandFailure(result), diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 6329d755959..7e618535ad8 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -210,7 +210,7 @@ describe("vcsActionState", () => { environmentId, cwd, }), - ).toBe(JSON.stringify([environmentId, cwd, null])); + ).toBe(JSON.stringify([environmentId, cwd])); expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); expect( getVcsActionTargetKey({ diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 714d58b25c5..45d9694a155 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -6,7 +6,6 @@ import { type GitRunStackedActionInput, type GitRunStackedActionResult, GitStackedAction, - ProjectId, type ProjectId as ProjectIdType, WS_METHODS, } from "@t3tools/contracts"; @@ -63,7 +62,7 @@ export interface VcsActionTarget { export interface ResolvedVcsActionTarget { readonly environmentId: EnvironmentIdType; readonly cwd: string; - readonly projectId: ProjectIdType | null; + readonly projectId?: ProjectIdType | null; } export interface BeginVcsActionInput { @@ -162,7 +161,7 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze({ const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); let nextLocalActionId = 0; const decodeVcsActionTargetKey = Schema.decodeUnknownSync( - Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(ProjectId)]), + Schema.Tuple([EnvironmentId, Schema.String]), ); export const vcsActionStateAtom = Atom.family((key: string) => { @@ -181,13 +180,16 @@ export function getVcsActionTargetKey(target: VcsActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; } - return JSON.stringify([target.environmentId, target.cwd, target.projectId ?? 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]); } export function parseVcsActionTargetKey(key: string): ResolvedVcsActionTarget { try { - const [environmentId, cwd, projectId] = decodeVcsActionTargetKey(JSON.parse(key)); - return { environmentId, cwd, projectId }; + const [environmentId, cwd] = decodeVcsActionTargetKey(JSON.parse(key)); + return { environmentId, cwd }; } catch (cause) { throw new VcsActionTargetKeyParseError({ keyLength: key.length, cause }); } @@ -463,7 +465,9 @@ export function createVcsActionManager( const rpcInput: GitRunStackedActionInput = { actionId: transportActionId, cwd: target.cwd, - ...(target.projectId ? { projectId: target.projectId } : {}), + // The command is cached per [environment, cwd]; the project context + // rides along from whichever target requested it. + ...(requestedTarget.projectId ? { projectId: requestedTarget.projectId } : {}), action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), From 605efda0e2153c2fb3f4fa63c7bc9ee93360bfc1 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:39:40 +0530 Subject: [PATCH 10/23] Pass projectId per stacked-action invocation The command cache is keyed by [environmentId, cwd], so a projectId captured from the first requester leaked into later invocations from other project views. Carry it on RunVcsStackedActionInput instead so every call sends its own project context, and widen the PR resolution input helper for exactOptionalPropertyTypes. Co-Authored-By: Claude Fable 5 --- apps/web/src/state/sourceControlActions.ts | 3 ++- packages/client-runtime/src/state/vcsAction.ts | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index ccf220cbc48..a223726abf5 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -244,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 } : {}), @@ -362,7 +363,7 @@ export interface PullRequestResolutionTarget { function pullRequestResolutionInput(target: { readonly cwd: string; - readonly projectId?: ProjectId | null; + readonly projectId?: ProjectId | null | undefined; readonly reference: string; }) { return { diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 45d9694a155..bb53581f0c9 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -56,13 +56,11 @@ export interface VcsActionState { export interface VcsActionTarget { readonly environmentId: EnvironmentIdType | null; readonly cwd: string | null; - readonly projectId?: ProjectIdType | null; } export interface ResolvedVcsActionTarget { readonly environmentId: EnvironmentIdType; readonly cwd: string; - readonly projectId?: ProjectIdType | null; } export interface BeginVcsActionInput { @@ -74,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; @@ -465,9 +464,10 @@ export function createVcsActionManager( const rpcInput: GitRunStackedActionInput = { actionId: transportActionId, cwd: target.cwd, - // The command is cached per [environment, cwd]; the project context - // rides along from whichever target requested it. - ...(requestedTarget.projectId ? { projectId: requestedTarget.projectId } : {}), + // 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 } : {}), From 3069d767f8e5d3f00bf584753a80cb64ed5031fd Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:50:42 +0530 Subject: [PATCH 11/23] Retrigger CI after CursorAdapter interrupt test flake Co-Authored-By: Claude Fable 5 From 59b30c25e0280bf5ad424878819c6d7f5e9d4fac Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:48:48 +0530 Subject: [PATCH 12/23] Refresh sibling status cache entries and track draft keys per commit Refresh all sibling cache entries (bare-cwd and project-scoped) on any status refresh instead of only project entries on bare-cwd refreshes, and clear project-settings draft keys only after the post-commit refresh lands so server-normalized values are not shadowed. Co-Authored-By: Claude Fable 5 --- apps/server/src/server.test.ts | 479 ++++++++++++++++++ .../src/vcs/VcsStatusBroadcaster.test.ts | 59 +++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 43 +- .../BranchToolbarBranchSelector.tsx | 5 +- apps/web/src/components/DiffPanel.tsx | 2 +- .../projects.$environmentId.$projectId.tsx | 252 ++++++--- 6 files changed, 741 insertions(+), 99 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a5249f4dfb9..67bed64a756 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -23,6 +23,7 @@ import { ORCHESTRATION_WS_METHODS, type PreviewEvent, ProjectId, + type ProjectSettingsPatch, ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, @@ -4756,6 +4757,484 @@ 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: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + projectSettings: { + [defaultProjectId]: { + 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("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 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; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 88e0365b309..babc5ca1d52 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -159,6 +159,65 @@ describe("VcsStatusBroadcaster", () => { }).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("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 0776d03e5a0..f39688bc3cb 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -218,17 +218,25 @@ export const make = Effect.gen(function* () { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(key) ?? null)); }); - const cachedProjectInputsForCwd = Effect.fn("VcsStatusBroadcaster.cachedProjectInputsForCwd")( - function* (cwd: string) { + 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 === cwd && projectId) { + if (keyCwd === input.cwd) { // Keys are only built from validated inputs, so the round-trip preserves the brand. - inputs.push({ cwd, projectId: projectId as VcsStatusInput["projectId"] }); + inputs.push( + projectId + ? { cwd: input.cwd, projectId: projectId as VcsStatusInput["projectId"] } + : { cwd: input.cwd }, + ); } } return inputs; @@ -397,15 +405,15 @@ export const make = Effect.gen(function* () { 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 projectInputs = yield* cachedProjectInputsForCwd(cwd); + const siblingInputs = yield* cachedSiblingInputsForCwd({ cwd }); yield* Effect.forEach( - projectInputs, - (projectInput) => + siblingInputs, + (siblingInput) => workflow - .localStatus(projectInput) + .localStatus(siblingInput) .pipe( Effect.flatMap((local) => - updateCachedLocalStatus(statusCacheKey(projectInput), local, { publish: true }), + updateCachedLocalStatus(statusCacheKey(siblingInput), local, { publish: true }), ), ), { concurrency: "unbounded", discard: true }, @@ -448,15 +456,14 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded", discard: true }, ); const result = yield* refreshStatusForInput(normalizedInput); - if (normalizedInput.projectId === undefined) { - // A bare-cwd refresh must also update project-scoped cache entries for the same - // repository, or subscribers keyed by projectId keep serving stale status. - const projectInputs = yield* cachedProjectInputsForCwd(normalizedInput.cwd); - yield* Effect.forEach(projectInputs, refreshStatusForInput, { - concurrency: "unbounded", - discard: true, - }); - } + // 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; }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 92d6ae8bc77..88e14386ee8 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -223,7 +223,10 @@ export function BranchToolbarBranchSelector({ ? null : vcsEnvironment.status({ environmentId, - input: { cwd: branchCwd }, + input: { + cwd: branchCwd, + ...(activeProject ? { projectId: activeProject.id } : {}), + }, }), ); const trimmedBranchQuery = branchQuery.trim(); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 158fdfc369f..5fdb7bdbce2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -225,7 +225,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff 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/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index c27b6cab882..c378564c9ec 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -25,7 +25,7 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_MODEL } from "@t3tools/contracts"; import type { ReactNode } from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { ensureLocalApi, readLocalApi } from "../localApi"; @@ -125,6 +125,37 @@ interface ProjectSettingsDraft { readonly disabledProviderInstanceIds?: ProviderInstanceId[]; } +type ProjectSettingsDraftKey = keyof Omit; + +const REMOTE_OVERRIDE_DRAFT_KEYS: readonly ProjectSettingsDraftKey[] = [ + "overrideEnabled", + "provider", + "remoteName", + "remoteUrl", + "webUrl", +]; + +interface ProjectMetaPatch { + readonly title?: string; + readonly defaultModelSelection?: ModelSelection | null; +} + +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(); @@ -328,64 +359,110 @@ function ProjectRouteView() { }, []); const settingsCommitQueueRef = useRef>(Promise.resolve()); + const inFlightCommitCountRef = useRef(0); + const draftKeysAwaitingRefreshRef = useRef>(new Set()); const refreshProjectDetails = projectDetails.refresh; - const commitProjectMeta = useCallback( - async (patch: { title?: string; defaultModelSelection?: ModelSelection | null }) => { - if (!project) return; - const result = await updateProject({ - environmentId: project.environmentId, - input: { - projectId: project.id, - ...patch, - }, - }); - if (result._tag === "Failure") { - // Drop the optimistic draft so the UI falls back to the persisted - // values instead of showing the rejected edit as saved. - setDraft(null); - refreshProjectDetails(); - showProjectSettingsError( - "Failed to update project settings", - squashAtomCommandFailure(result), - ); - return; + const clearDraftKeys = useCallback((keys: Iterable) => { + setDraft((current) => { + if (!current) 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); + }); + }, []); + + // Drop staged values once the refresh that follows their successful commit + // has landed, so the UI resynchronizes with server-normalized data (trimmed + // strings, dropped empty keys) and later refreshes are not shadowed by the + // draft. Keys are held while any commit is still in flight to keep the + // optimistic value visible until the final refresh. + const detailsPending = projectDetails.isPending; + useEffect(() => { + if (detailsPending) return; + if (inFlightCommitCountRef.current > 0) return; + if (draftKeysAwaitingRefreshRef.current.size === 0) return; + const keys = [...draftKeysAwaitingRefreshRef.current]; + draftKeysAwaitingRefreshRef.current.clear(); + clearDraftKeys(keys); + }, [clearDraftKeys, details, detailsPending]); + + const runCommit = useCallback((task: () => Promise) => { + inFlightCommitCountRef.current += 1; + const nextCommit = settingsCommitQueueRef.current + .catch(() => undefined) + .then(task) + .finally(() => { + inFlightCommitCountRef.current -= 1; + }); + settingsCommitQueueRef.current = nextCommit.catch(() => undefined); + return nextCommit; + }, []); + + const handleCommitFailure = useCallback( + (error: unknown) => { + // Drop the optimistic draft so the UI falls back to the persisted + // values instead of showing the rejected edit as saved. + setDraft(null); + draftKeysAwaitingRefreshRef.current.clear(); refreshProjectDetails(); + showProjectSettingsError("Failed to update project settings", error); }, - [project, refreshProjectDetails, showProjectSettingsError, updateProject], + [refreshProjectDetails, showProjectSettingsError], + ); + + const commitProjectMeta = useCallback( + (patch: ProjectMetaPatch) => + runCommit(async () => { + if (!project) return; + const result = await updateProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + ...patch, + }, + }); + if (result._tag === "Failure") { + handleCommitFailure(squashAtomCommandFailure(result)); + return; + } + for (const key of draftKeysForMetaPatch(patch)) { + draftKeysAwaitingRefreshRef.current.add(key); + } + refreshProjectDetails(); + }), + [handleCommitFailure, project, refreshProjectDetails, runCommit, updateProject], ); const commitProjectSettings = useCallback( - (patch: ProjectSettingsPatch) => { - const nextCommit = settingsCommitQueueRef.current - .catch(() => undefined) - .then(async () => { - if (!project) return; - const result = await updateProjectSettings({ - environmentId: project.environmentId, - input: { - projectId: project.id, - patch, - }, - }); - if (result._tag === "Failure") { - // Drop the optimistic draft so the UI falls back to the persisted - // values instead of showing the rejected edit as saved. - setDraft(null); - refreshProjectDetails(); - showProjectSettingsError( - "Failed to update project settings", - squashAtomCommandFailure(result), - ); - return; - } - refreshProjectDetails(); + (patch: ProjectSettingsPatch) => + runCommit(async () => { + if (!project) return; + const result = await updateProjectSettings({ + environmentId: project.environmentId, + input: { + projectId: project.id, + patch, + }, }); - settingsCommitQueueRef.current = nextCommit.catch(() => undefined); - return nextCommit; - }, - [project, refreshProjectDetails, showProjectSettingsError, updateProjectSettings], + if (result._tag === "Failure") { + handleCommitFailure(squashAtomCommandFailure(result)); + return; + } + for (const key of draftKeysForSettingsPatch(patch)) { + draftKeysAwaitingRefreshRef.current.add(key); + } + refreshProjectDetails(); + }), + [handleCommitFailure, project, refreshProjectDetails, runCommit, updateProjectSettings], ); const persistRemoteOverrideIfValid = useCallback( @@ -400,50 +477,44 @@ function ProjectRouteView() { const commitTitle = useCallback( (nextTitle: string) => { const trimmed = nextTitle.trim(); - if (!projectDetails.data) return; + if (!details) return; if (trimmed.length === 0) { - stageDraft({ title: projectDetails.data.title }); + clearDraftKeys(["title"]); 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 }); - if (trimmed !== projectDetails.data.title) { - void commitProjectMeta({ title: trimmed }); - } + void commitProjectMeta({ title: trimmed }); }, - [commitProjectMeta, projectDetails.data, showProjectSettingsError, stageDraft], + [clearDraftKeys, commitProjectMeta, details, showProjectSettingsError, stageDraft, title], ); const commitDefaultModelSelection = useCallback( (nextSelection: ModelSelection | null) => { + if (isModelSelectionEqual(nextSelection, defaultModelSelection)) return; stageDraft({ defaultModelSelection: nextSelection }); - if ( - !isModelSelectionEqual(nextSelection, projectDetails.data?.defaultModelSelection ?? null) - ) { - void commitProjectMeta({ defaultModelSelection: nextSelection }); - } + void commitProjectMeta({ defaultModelSelection: nextSelection }); }, - [commitProjectMeta, projectDetails.data?.defaultModelSelection, stageDraft], + [commitProjectMeta, defaultModelSelection, stageDraft], ); const commitAutomaticGitFetchInterval = useCallback( (nextIntervalMs: number | null) => { + if (nextIntervalMs === automaticGitFetchInterval) return; stageDraft({ automaticGitFetchInterval: nextIntervalMs }); - const currentIntervalMs = projectDetails.data?.settings.automaticGitFetchInterval ?? null; - if (nextIntervalMs === currentIntervalMs) { - return; - } void commitProjectSettings({ automaticGitFetchInterval: nextIntervalMs }); }, - [commitProjectSettings, projectDetails.data?.settings.automaticGitFetchInterval, stageDraft], + [automaticGitFetchInterval, commitProjectSettings, stageDraft], ); const commitActionEnvironment = useCallback( (nextEnvironment: ProjectActionEnvironment) => { - stageDraft({ actionEnvironment: nextEnvironment }); let normalized: ProjectActionEnvironment; try { normalized = normalizeActionEnvironment(nextEnvironment); @@ -470,16 +541,13 @@ function ProjectRouteView() { ); return; } - if (!isStringRecordEqual(normalized, projectDetails.data?.settings.actionEnvironment ?? {})) { - void commitProjectSettings({ actionEnvironment: normalized }); - } + 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 }); }, - [ - commitProjectSettings, - projectDetails.data?.settings.actionEnvironment, - showProjectSettingsError, - stageDraft, - ], + [actionEnvironment, commitProjectSettings, showProjectSettingsError, stageDraft], ); const commitProviderInstanceAllowed = useCallback( @@ -1015,11 +1083,33 @@ function ProjectRouteView() { }} /> + {buildRemoteOverride({ + enabled: true, + provider, + remoteName, + remoteUrl, + webUrl, + }) === null ? ( +

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

+ ) : null}
) : null} } /> + + { 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 (trimmedNextKey.length > 0 && duplicateKey) { + if (duplicateKey) { toastManager.add( stackedThreadToast({ type: "error", @@ -1327,7 +1421,7 @@ function ActionEnvironmentEditor({ const next = { ...environment }; const value = next[previousKey] ?? ""; delete next[previousKey]; - next[nextKey] = value; + next[trimmedNextKey] = value; onChange(next); }; From 23eb95e6c8e8455f9e41a5c5555390fbb60887b1 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:08:35 +0530 Subject: [PATCH 13/23] Refine project settings navigation --- apps/web/src/components/SidebarV2.tsx | 402 +----------------- .../projects.$environmentId.$projectId.tsx | 201 ++++++++- 2 files changed, 201 insertions(+), 402 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 5e152449173..941e2069349 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,7 +22,6 @@ import { CircleCheckIcon, CircleDashedIcon, ClockIcon, - CopyIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, @@ -31,9 +30,7 @@ import { PlusIcon, SearchIcon, ServerIcon, - SettingsIcon, SquarePenIcon, - Trash2Icon, Undo2Icon, } from "lucide-react"; import { @@ -70,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"; @@ -86,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"; @@ -111,7 +101,6 @@ import { resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, - shouldNavigateAfterProjectRemoval, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, @@ -135,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; @@ -1012,34 +982,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( @@ -1203,156 +1145,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 @@ -2555,192 +2361,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/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index 34163745be0..85779aae9f5 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"; +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"; @@ -21,6 +21,7 @@ import type { ProjectRemoteOverride, ProjectScript, ProjectSettingsPatch, + SidebarProjectGroupingMode, SourceControlProviderKind, } from "@t3tools/contracts"; import { DEFAULT_MODEL } from "@t3tools/contracts"; @@ -61,7 +62,11 @@ import ProjectScriptsControl, { } from "../components/ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId } from "../projectScripts"; import { syncProjectScriptKeybinding } from "../lib/projectScriptKeybindings"; -import { usePrimarySettings } from "../hooks/useSettings"; +import { + useClientSettings, + usePrimarySettings, + useUpdateClientSettings, +} from "../hooks/useSettings"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionForInstance, @@ -84,13 +89,15 @@ import { ProviderInstanceIcon } from "../components/chat/ProviderInstanceIcon"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; -import { usePrimaryEnvironmentId } from "../state/environments"; +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", @@ -103,6 +110,11 @@ const PROVIDER_LABELS: Record = { 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[] = []; @@ -216,10 +228,63 @@ function ProjectRouteView() { 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(); @@ -543,6 +608,21 @@ function ProjectRouteView() { ], ); + 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; @@ -851,7 +931,7 @@ function ProjectRouteView() { return (
-
+
- } - /> - + + + +
+ 0 + ? "All project threads will be deleted." + : "No threads will be deleted." + } + control={ + + } + /> +
+
+
+ ) : null} @@ -1560,19 +1575,14 @@ function ProjectSettingRow({ const hasChildren = Boolean(children); const alignStart = align === "start" || hasChildren || description !== undefined; return ( -
+
-
+
{title}
@@ -1580,7 +1590,7 @@ function ProjectSettingRow({
{description ? ( -
+
{description}
) : null} From 14a687e1afc5041bb091a9b26cdcd67289092798 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:40:18 +0530 Subject: [PATCH 16/23] Align project settings grid --- .../projects.$environmentId.$projectId.tsx | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index e6152b081c6..275d57e4f11 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -959,7 +959,7 @@ function ProjectRouteView() { {project && projectDetails.isPending && !projectDetails.data ? ( ) : ( - + {!project ? ( ) : projectDetails.error !== null ? ( @@ -1028,10 +1028,13 @@ function ProjectRouteView() {
-
-
- -
+
+
+ +
- -
+ +
{globallyEnabledProviderInstanceEntries.map((entry) => { const allowed = !disabledProviderInstanceIds.includes(entry.instanceId); const isLastAllowedProvider = @@ -1196,9 +1202,12 @@ function ProjectRouteView() {
-
- -
+
+ +
- -
+ +
- -
+ +
Date: Sat, 25 Jul 2026 10:46:19 +0530 Subject: [PATCH 17/23] Polish project settings alignment --- .../projects.$environmentId.$projectId.tsx | 104 ++++++++---------- 1 file changed, 43 insertions(+), 61 deletions(-) diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index 275d57e4f11..e4b924b993a 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -959,7 +959,7 @@ function ProjectRouteView() { {project && projectDetails.isPending && !projectDetails.data ? ( ) : ( - + {!project ? ( ) : projectDetails.error !== null ? ( @@ -1028,13 +1028,10 @@ function ProjectRouteView() {
-
-
- -
+
+
+ +
+
- -
+ +
{globallyEnabledProviderInstanceEntries.map((entry) => { const allowed = !disabledProviderInstanceIds.includes(entry.instanceId); const isLastAllowedProvider = @@ -1161,7 +1155,7 @@ function ProjectRouteView() { return (
+ + +
+ 0 + ? "All project threads will be deleted." + : "No threads will be deleted." + } + control={ + + } + /> +
+
-
- -
+
+ +
-
+
Custom - -
+ +
- - -
- 0 - ? "All project threads will be deleted." - : "No threads will be deleted." - } - control={ - - } - /> -
-
@@ -1510,7 +1495,7 @@ function ProjectPathLink({ path }: { path: string }) { return (
) : ( -
- No project actions configured. +
+
+
Actions
+
+ No project actions configured. +
+
+
)} -
- -
+ {scripts.length > 0 ? ( +
+ +
+ ) : null}
); } diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index e4b924b993a..9f2ad1c4240 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -1028,8 +1028,8 @@ function ProjectRouteView() {
-
-
+
+
commitDefaultModelSelection( @@ -1120,7 +1120,7 @@ function ProjectRouteView() { modelOptions={displayedModelSelection.options} allowPromptInjectedEffort={false} triggerVariant="outline" - triggerClassName="max-w-md" + triggerClassName="max-w-32 sm:max-w-36" onModelOptionsChange={(nextOptions) => commitDefaultModelSelection( createModelSelection( @@ -1141,86 +1141,9 @@ function ProjectRouteView() { />
- - -
- {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={duplicateDriverCount > 1 ? "size-5" : "size-4"} - iconClassName="size-4" - badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" - /> -
-
- {entry.displayName} -
-
- {entry.instanceId} -
-
-
- - commitProviderInstanceAllowed(entry.instanceId, Boolean(checked)) - } - /> -
- ); - })} -
-
- - -
- 0 - ? "All project threads will be deleted." - : "No threads will be deleted." - } - control={ - - } - /> -
-
-
+
+
- -
- - +
+ {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={duplicateDriverCount > 1 ? "size-5" : "size-4"} + iconClassName="size-4" + badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" + /> +
+
+ {entry.displayName} +
+
+ {entry.instanceId} +
+
+
+ + commitProviderInstanceAllowed(entry.instanceId, Boolean(checked)) + } /> - } - /> +
+ ); + })} +
+ + + +
+ + + } + /> +
+
+ +
+
+
Remove project
+
+ {projectThreadCount > 0 + ? "All project threads will be deleted." + : "No threads will be deleted."}
- +
+
From de7b7be5efe52efab8b66d9ef8e5dcc15cecbd87 Mon Sep 17 00:00:00 2001 From: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:19:45 +0530 Subject: [PATCH 19/23] Fill project settings viewport --- .../projects.$environmentId.$projectId.tsx | 742 +++++++++--------- 1 file changed, 370 insertions(+), 372 deletions(-) diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index 9f2ad1c4240..c47c64c6d66 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -48,11 +48,7 @@ import { 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 { SettingResetButton, SettingsPageContainer } from "../components/settings/settingsLayout"; import { toastManager, stackedThreadToast } from "../components/ui/toast"; import { DraftInput } from "../components/ui/draft-input"; import { isElectron } from "../env"; @@ -959,7 +955,7 @@ function ProjectRouteView() { {project && projectDetails.isPending && !projectDetails.data ? ( ) : ( - + {!project ? ( ) : projectDetails.error !== null ? ( @@ -1028,370 +1024,356 @@ function ProjectRouteView() {
-
-
- -
- - } - /> - { - 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, +
+ + + } + /> + { + 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, - }) === 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)), - ), - ) - } - > - - - - - - - seconds
- } - /> -
- -
- - -
- {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 ( -
+ {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)), + ), + ) + } > -
- 1} - className={duplicateDriverCount > 1 ? "size-5" : "size-4"} - iconClassName="size-4" - badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" - /> -
-
- {entry.displayName} -
-
- {entry.instanceId} -
+ + + + + + + seconds +
+ } + /> + + + + {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={duplicateDriverCount > 1 ? "size-5" : "size-4"} + iconClassName="size-4" + badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]" + /> +
+
+ {entry.displayName} +
+
+ {entry.instanceId}
- - commitProviderInstanceAllowed(entry.instanceId, Boolean(checked)) - } - />
- ); - })} -
- + + commitProviderInstanceAllowed(entry.instanceId, Boolean(checked)) + } + /> +
+ ); + })} + - -
+ +
- - } - />
- + + } + /> +
-
+
Remove project
@@ -1550,6 +1532,22 @@ function formatEffectiveGitRemoteValue(remote: ProjectEffectiveRemote) { return remote.remoteUrl; } +/** + * Section that stretches to fill its grid track: the row list grows with the + * quadrant so leftover viewport height turns into even row spacing instead of + * pooling below the content. + */ +function ProjectSettingsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ); +} + function ProjectSettingRow({ title, description, @@ -1570,7 +1568,7 @@ function ProjectSettingRow({ const hasChildren = Boolean(children); const alignStart = align === "start" || hasChildren || description !== undefined; return ( -
+
Date: Sat, 25 Jul 2026 13:02:16 +0530 Subject: [PATCH 20/23] Refine project settings layout --- .../src/components/ProjectScriptsControl.tsx | 86 +- apps/web/src/components/SidebarV2.tsx | 10 +- .../projects.$environmentId.$projectId.tsx | 809 +++++++++--------- 3 files changed, 459 insertions(+), 446 deletions(-) diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index ac441a5c04b..7fdfa5f532b 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -19,6 +19,7 @@ import { PlayIcon, PlusIcon, SettingsIcon, + Trash2Icon, WrenchIcon, } from "lucide-react"; import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react"; @@ -270,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); @@ -332,6 +339,26 @@ export default function ProjectScriptsControl({ if (variant === "settings") { return (
+
+
Actions
+ {scripts.length === 0 ? ( +
+ No project actions configured. +
+ ) : ( +
+ )} + +
{scripts.length > 0 ? (
{scripts.map((script) => { @@ -342,7 +369,7 @@ export default function ProjectScriptsControl({ return (
@@ -367,47 +394,30 @@ export default function ProjectScriptsControl({ {script.command}
- +
+ + +
); })}
- ) : ( -
-
-
Actions
-
- No project actions configured. -
-
- -
- )} - {scripts.length > 0 ? ( -
- -
) : null}
); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 941e2069349..0905f3cdd49 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -25,11 +25,11 @@ import { FolderIcon, FolderPlusIcon, GitBranchIcon, - EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, ServerIcon, + Settings2Icon, SquarePenIcon, Undo2Icon, } from "lucide-react"; @@ -1989,7 +1989,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, @@ -2116,7 +2116,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); }} > - + ); diff --git a/apps/web/src/routes/projects.$environmentId.$projectId.tsx b/apps/web/src/routes/projects.$environmentId.$projectId.tsx index c47c64c6d66..14c28e36d6a 100644 --- a/apps/web/src/routes/projects.$environmentId.$projectId.tsx +++ b/apps/web/src/routes/projects.$environmentId.$projectId.tsx @@ -48,7 +48,11 @@ import { import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; import { Spinner } from "../components/ui/spinner"; import { Switch } from "../components/ui/switch"; -import { SettingResetButton, SettingsPageContainer } from "../components/settings/settingsLayout"; +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"; @@ -925,7 +929,7 @@ function ProjectRouteView() { (entry) => entry.instanceId === displayedModelSelection.instanceId, ) ?? null; return ( - +
@@ -955,7 +959,7 @@ function ProjectRouteView() { {project && projectDetails.isPending && !projectDetails.data ? ( ) : ( - + {!project ? ( ) : projectDetails.error !== null ? ( @@ -1024,396 +1028,400 @@ function ProjectRouteView() {
-
- - - } - /> - { - if ( - value === "inherit" || - value === "repository" || - value === "repository_path" || - value === "separate" - ) { - updateProjectGroupingPreference(value); - } - }} + + + } + /> + { + 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={ -
- + {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-48 sm:max-w-52" - disabled={projectProviderInstanceEntries.length === 0} - onInstanceModelChange={(instanceId, model) => - commitDefaultModelSelection(createModelSelection(instanceId, model)) + triggerClassName="max-w-32 sm:max-w-36" + onModelOptionsChange={(nextOptions) => + commitDefaultModelSelection( + createModelSelection( + displayedModelSelection.instanceId, + displayedModelSelection.model, + nextOptions, + ), + ) } /> - {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} +
+ } + /> + } + /> +
+ + + + ) + } + 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 ? ( +
+ +