diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 530e0488cf3..ee35822a786 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"; @@ -649,6 +650,8 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; + sourceControlProviderContext?: SourceControlProviderRegistry.SourceControlProviderHandle["context"]; + sourceControlProviderContextSource?: SourceControlProviderRegistry.SourceControlProviderHandle["contextSource"]; textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunnerShape; }) { @@ -671,7 +674,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([]), }), @@ -706,6 +714,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* () { @@ -749,6 +769,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 8dfb957b89d..e2010d2108d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -788,6 +788,18 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { cwd: string, branch: string | null, ) { + const providerHandle = yield* sourceControlProviders.resolveHandle({ cwd }).pipe( + Effect.catch(() => + Effect.succeed({ + context: null, + contextSource: null, + }), + ), + ); + if (providerHandle.contextSource === "override" && providerHandle.context) { + return providerHandle.context.provider; + } + const preferredRemoteName = branch === null ? "origin" @@ -796,7 +808,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(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* ( diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 1a13229fd8e..af13ff58629 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import { describe, expect, it, vi } from "vitest"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; @@ -50,6 +51,7 @@ describe("ProjectSetupScriptRunner", () => { Effect.provide( ProjectSetupScriptRunnerLive.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( Layer.succeed(TerminalManager, { open, @@ -109,6 +111,20 @@ describe("ProjectSetupScriptRunner", () => { Effect.provide( ProjectSetupScriptRunnerLive.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), + Layer.provideMerge( + ServerSettingsService.layerTest({ + projectSettings: { + [project.id]: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: { + API_BASE_URL: "https://api.example.test", + }, + disabledProviderInstanceIds: [], + }, + }, + }), + ), Layer.provideMerge( Layer.succeed(TerminalManager, { open, @@ -146,6 +162,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", }, diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 61cd043b43b..d382bdd5be2 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; import { TerminalManager } from "../../terminal/Services/Manager.ts"; import { type ProjectSetupScriptRunnerShape, @@ -14,6 +15,7 @@ import { const makeProjectSetupScriptRunner = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettingsService; const terminalManager = yield* TerminalManager; const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => @@ -46,9 +48,12 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; const cwd = input.worktreePath; + const settings = yield* serverSettings.getSettings; + const actionEnvironment = settings.projectSettings[project.id]?.actionEnvironment ?? {}; const env = projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: input.worktreePath, + extraEnv: actionEnvironment, }); yield* terminalManager.open({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 455cac1eb08..d5aa36d277e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -21,6 +21,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"; @@ -252,19 +253,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 8394897c48c..9c257a6473e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -535,6 +535,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, }), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 1da9a63070f..dd2c90f2a04 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 Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -25,10 +26,7 @@ import { } from "./serverRuntimeStartup.ts"; it("uses the canonical Codex default for auto-bootstrapped model selection", () => { - assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); + assert.deepStrictEqual(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 59fde342280..33ea0b1c137 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 Data from "effect/Data"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -153,10 +152,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; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 7af27f0b7cf..ebe96b779a8 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, @@ -141,6 +142,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* 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* ServerSettingsService; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5ea2e03813f..4f3ce8e7e69 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -16,6 +16,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, @@ -123,6 +132,12 @@ export interface ServerSettingsShape { patch: ServerSettingsPatch, ) => Effect.Effect; + /** Update one project's settings from the latest persisted snapshot. */ + readonly updateProjectSettings: ( + projectId: ProjectId, + patch: ProjectSettingsPatch, + ) => Effect.Effect; + /** Stream of settings change events. */ readonly streamChanges: Stream.Stream; } @@ -144,16 +159,35 @@ export class ServerSettingsService extends Context.Service< : {}), }); 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 ServerSettingsShape; @@ -539,6 +573,23 @@ const makeServerSettings = 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), @@ -546,21 +597,18 @@ const makeServerSettings = 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 d1c6c65c752..3b7f6457367 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 { 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"; 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)({ + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + ServerSettingsService.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 829f6be1eb9..26316a41ef2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -6,6 +6,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ServerConfig } from "../config.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ServerSettingsService } 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"; @@ -13,6 +15,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"); @@ -24,6 +27,17 @@ function makeRegistry(input: { }>; }) { 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) => ({ @@ -67,6 +81,10 @@ function makeRegistry(input: { Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(VcsProcess.VcsProcess)({}), + Layer.mock(ProjectionSnapshotQuery)({ + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + }), + ServerSettingsService.layerTest(), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( Layer.provide(NodeServices.layer), ), @@ -87,6 +105,23 @@ 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("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 28826e764b0..24e0bfb1c91 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -4,6 +4,7 @@ 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 { SourceControlProviderError, type SourceControlProviderDiscoveryItem, @@ -17,7 +18,10 @@ import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; +import { providerContextFromOverride } from "./RemoteOverride.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"; @@ -33,6 +37,7 @@ export interface SourceControlProviderRegistration { export interface SourceControlProviderHandle { readonly provider: SourceControlProvider.SourceControlProviderShape; readonly context: SourceControlProvider.SourceControlProviderContext | null; + readonly contextSource: "override" | "detected" | null; } export interface SourceControlProviderRegistryShape { @@ -160,6 +165,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< @@ -176,17 +183,45 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit 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 (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))); - return selectProviderContext(remotes.remotes); + const context = selectProviderContext(remotes.remotes); + return { context, source: context ? ("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, @@ -195,12 +230,13 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => Cache.get(providerContextCache, input.cwd).pipe( - Effect.map((context) => { + 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; }), ); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2bd2d5c00da..53e5733ca08 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -275,7 +275,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/ws.ts b/apps/server/src/ws.ts index 001c9baff9e..a6254a14093 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -24,15 +24,22 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + ProjectDetailsError, ProjectSearchEntriesError, + type ProjectDetectedRemote, + type ProjectEffectiveRemote, + type ProjectRemoteOverride, + type ProjectSettingsPatch, ProjectWriteFileError, OrchestrationReplayEventsError, FilesystemBrowseError, + type ProjectId, ThreadId, type TerminalEvent, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -53,7 +60,11 @@ import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; -import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; +import { + emptyProjectSettings, + redactServerSettingsForClient, + ServerSettingsService, +} from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; @@ -74,6 +85,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"; @@ -116,6 +128,59 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< const PROVIDER_STATUS_DEBOUNCE_MS = 200; +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: BootstrapCredentialChange | SessionCredentialChange, revision: number, @@ -164,6 +229,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; const open = yield* Open; + const gitCore = yield* GitVcsDriver.GitVcsDriver; const gitWorkflow = yield* GitWorkflowService; const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; @@ -545,19 +611,62 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ); }); + 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) @@ -603,6 +712,117 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .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] ?? 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( @@ -939,6 +1159,81 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "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, @@ -991,7 +1286,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcStream( WS_METHODS.subscribeVcsStatus, vcsStatusBroadcaster.streamStatus(input, { - automaticRemoteRefreshInterval: automaticGitFetchInterval, + automaticRemoteRefreshInterval: automaticGitFetchIntervalForProject(input.projectId), }), { "rpc.aggregate": "vcs", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..e277fc7760d 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -206,7 +206,48 @@ function createMockEnvironmentApi(input: { }): EnvironmentApi { return { terminal: {} as EnvironmentApi["terminal"], - projects: {} as EnvironmentApi["projects"], + projects: { + getDetails: vi.fn(async () => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + scripts: [], + settings: { + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + }, + detected: { + gitRoot: "/repo/project", + branch: "main", + remotes: [], + primaryRemote: null, + }, + effective: { + title: "Project", + remote: null, + }, + })), + updateSettings: vi.fn(async () => ({ + remoteOverride: null, + automaticGitFetchInterval: null, + actionEnvironment: {}, + disabledProviderInstanceIds: [], + })), + searchEntries: vi.fn(async () => ({ + entries: [], + truncated: false, + })), + writeFile: vi.fn(async (input) => ({ + relativePath: input.relativePath, + })), + }, filesystem: { browse: input.browse, }, @@ -558,6 +599,16 @@ function composerDraftFor(target: string) { return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; } +function expectProjectDefaultDraftModel(target: string) { + const codexInstanceId = ProviderInstanceId.make("codex"); + const draft = composerDraftFor(target); + expect(draft?.modelSelectionByProvider[codexInstanceId]).toEqual( + createModelSelection(codexInstanceId, "gpt-5"), + ); + expect(draft?.activeProvider).toBe("codex"); + return draft; +} + function draftIdFromPath(pathname: string) { const segments = pathname.split("/"); const draftId = segments[segments.length - 1]; @@ -4042,7 +4093,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("snapshots sticky codex settings into a new draft thread", async () => { + it("uses the project default codex model for a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { [ProviderInstanceId.make("codex")]: createModelSelection( @@ -4078,26 +4129,13 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const newDraftId = draftIdFromPath(newThreadPath); - // `toMatchObject` matches objects loosely (extras ignored) but compares - // arrays strictly, so wrap `options` in `arrayContaining` to keep the - // assertion focused on sticky `fastMode` carrying over without asserting - // on exactly which other options are preserved. - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), - }, - }, - activeProvider: "codex", - }); + expectProjectDefaultDraftModel(newDraftId); } finally { await mounted.cleanup(); } }); - it("hydrates the provider alongside a sticky claude model", async () => { + it("uses the project default provider over a sticky claude model", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { [ProviderInstanceId.make("claudeAgent")]: createModelSelection( @@ -4129,29 +4167,20 @@ describe("ChatView timeline estimator parity (full app)", () => { const newThreadPath = await waitForURL( mounted.router, (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new sticky claude draft thread UUID.", + "Route should have changed to a new project-default draft thread UUID.", ); const newDraftId = draftIdFromPath(newThreadPath); - expect(composerDraftFor(newDraftId)).toMatchObject({ - modelSelectionByProvider: { - claudeAgent: createModelSelection( - ProviderInstanceId.make("claudeAgent"), - "claude-opus-4-6", - [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ], - ), - }, - activeProvider: "claudeAgent", - }); + const draft = expectProjectDefaultDraftModel(newDraftId); + expect(draft?.modelSelectionByProvider[ProviderInstanceId.make("claudeAgent")]).toBe( + undefined, + ); } finally { await mounted.cleanup(); } }); - it("falls back to defaults when no sticky composer settings exist", async () => { + it("seeds project defaults when no sticky composer settings exist", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -4173,13 +4202,13 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const newDraftId = draftIdFromPath(newThreadPath); - expect(composerDraftFor(newDraftId)).toBe(undefined); + expectProjectDefaultDraftModel(newDraftId); } finally { await mounted.cleanup(); } }); - it("prefers draft state over sticky composer settings and defaults", async () => { + it("keeps existing draft state over sticky composer settings and project defaults", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { [ProviderInstanceId.make("codex")]: createModelSelection( @@ -4215,19 +4244,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const draftId = draftIdFromPath(threadPath); - // See the note on the sibling sticky-codex test: arrays match strictly - // under `toMatchObject`, so use `arrayContaining` to keep the assertion - // scoped to the sticky trait (`fastMode`) that must carry over. - expect(composerDraftFor(draftId)).toMatchObject({ - modelSelectionByProvider: { - codex: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.3-codex", - options: expect.arrayContaining([{ id: "fastMode", value: true }]), - }, - }, - activeProvider: "codex", - }); + expectProjectDefaultDraftModel(draftId); useComposerDraftStore.getState().setModelSelection( draftId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..7139f4fb030 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -29,6 +29,7 @@ import { } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, + createDefaultModelSelection, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; @@ -107,7 +108,7 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; +import { syncProjectScriptKeybinding } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, @@ -195,6 +196,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_ACTION_ENVIRONMENT: Record = {}; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type EnvironmentUnavailableState = { @@ -793,10 +795,7 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, + fallbackDraftProject?.defaultModelSelection ?? createDefaultModelSelection(), localDraftError, ) : undefined, @@ -1142,6 +1141,10 @@ export default function ChatView(props: ChatViewProps) { primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId ? primaryServerConfig : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); + const activeEnvironmentSettings = useMemo( + () => (serverConfig ? { ...settings, ...serverConfig.settings } : settings), + [serverConfig, settings], + ); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -1630,7 +1633,11 @@ export default function ChatView(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; - const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); + const gitStatusQuery = useGitStatus({ + environmentId, + cwd: gitCwd, + projectId: activeProject?.id ?? null, + }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); // Prefer an instance-id match so a custom Codex instance (e.g. @@ -1654,6 +1661,11 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.cwd ?? 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 = terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext @@ -1904,7 +1916,10 @@ export default function ChatView(props: ChatViewProps) { cwd: activeProject.cwd, }, worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), + extraEnv: { + ...activeProjectActionEnvironment, + ...options?.env, + }, }); const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { @@ -1943,6 +1958,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, activeThreadId, activeThreadRef, + activeProjectActionEnvironment, gitCwd, setTerminalOpen, setThreadError, @@ -1975,20 +1991,20 @@ export default function ChatView(props: ChatViewProps) { scripts: input.nextScripts, }); - const keybindingRule = decodeProjectScriptKeybindingRule({ + const keybindingServer = + isElectron && input.keybinding !== undefined ? readLocalApi()?.server : null; + if (isElectron && input.keybinding !== undefined && !keybindingServer) { + throw new Error("Local API unavailable."); + } + + await syncProjectScriptKeybinding({ + keybindings, keybinding: input.keybinding, command: input.keybindingCommand, + server: keybindingServer, }); - - if (isElectron && keybindingRule) { - const localApi = readLocalApi(); - if (!localApi) { - throw new Error("Local API unavailable."); - } - await localApi.server.upsertKeybinding(keybindingRule); - } }, - [environmentId], + [environmentId, keybindings], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -3646,7 +3662,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadModelSelection={activeThread?.modelSelection} activeThreadActivities={activeThread?.activities} resolvedTheme={resolvedTheme} - settings={settings} + settings={activeEnvironmentSettings} keybindings={keybindings} terminalOpen={Boolean(terminalState.terminalOpen)} gitCwd={gitCwd} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f178a69fb43..a62d46ef2a5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -686,7 +686,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { + + ); + })} + + ) : ( +
+ No project actions configured. +
+ )} +
+ +
+ + ); + } + + if (primaryScript) { + return ( - )} + ); + } + + return ( + + ); + })(); + + return ( + <> + {trigger} { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3d0ccd3ab7c..34a5351b78b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -121,7 +121,6 @@ import { DialogPopup, DialogTitle, } from "./ui/dialog"; -import { Input } from "./ui/input"; import { Menu, MenuGroup, @@ -372,6 +371,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, + projectId: thread.projectId, }); const isHighlighted = isActive || isSelected; const isThreadRunning = @@ -1061,10 +1061,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< @@ -1230,28 +1226,51 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - toggleProject(project.projectKey); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/projects/$projectId", + params: { projectId: project.id }, + }); }, [ clearSelection, dragInProgressRef, - project.projectKey, + isMobile, + project.id, + router, + setOpenMobile, suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef, - toggleProject, ], ); 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; + } toggleProject(project.projectKey); }, - [dragInProgressRef, project.projectKey, toggleProject], + [dragInProgressRef, project.projectKey, suppressProjectClickAfterDragRef, toggleProject], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -1272,11 +1291,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [suppressProjectClickAfterDragRef, suppressProjectClickForContextMenuRef], ); - const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { - setProjectRenameTarget(member); - setProjectRenameTitle(member.name); - }, []); - const openProjectGroupingDialog = useCallback( (member: SidebarProjectGroupMember) => { const overrideKey = deriveProjectGroupingOverrideKey(member); @@ -1435,7 +1449,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const actionHandlers = new Map Promise | void>(); const makeLeaf = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "settings" | "grouping" | "delete", member: SidebarProjectGroupMember, options?: { destructive?: boolean; @@ -1445,15 +1459,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const id = `${action}:${member.physicalProjectKey}`; actionHandlers.set(id, () => { switch (action) { - case "rename": - openProjectRenameDialog(member); + case "settings": + void router.navigate({ + to: "/projects/$projectId", + params: { projectId: member.id }, + }); return; case "grouping": openProjectGroupingDialog(member); return; - case "copy-path": - copyPathToClipboard(member.cwd, { path: member.cwd }); - return; case "delete": return handleRemoveProject(member); } @@ -1468,7 +1482,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; const buildTargetedItem = ( - action: "rename" | "grouping" | "copy-path" | "delete", + action: "settings" | "grouping" | "delete", label: string, options?: { destructive?: boolean; @@ -1500,9 +1514,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const clicked = await api.contextMenu.show( [ - buildTargetedItem("rename", "Rename project"), + buildTargetedItem("settings", "Open project settings"), buildTargetedItem("grouping", "Project grouping…"), - buildTargetedItem("copy-path", "Copy Project Path"), buildTargetedItem("delete", "Remove project", { destructive: true, }), @@ -1521,12 +1534,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec })(); }, [ - copyPathToClipboard, handleRemoveProject, openProjectGroupingDialog, - openProjectRenameDialog, project.groupedProjectCount, project.memberProjects, + router, suppressProjectClickForContextMenuRef, ], ); @@ -1816,61 +1828,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [], ); - const closeProjectRenameDialog = useCallback(() => { - setProjectRenameTarget(null); - setProjectRenameTitle(""); - }, []); - - const submitProjectRename = useCallback(async () => { - if (!projectRenameTarget) { - return; - } - - const trimmed = projectRenameTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ - type: "warning", - title: "Project title cannot be empty", - }); - return; - } - - if (trimmed === projectRenameTarget.name) { - closeProjectRenameDialog(); - return; - } - - const api = readEnvironmentApi(projectRenameTarget.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: "Project API unavailable.", - }), - ); - return; - } - - try { - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: projectRenameTarget.id, - title: trimmed, - }); - closeProjectRenameDialog(); - } catch (error) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); - const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); setProjectGroupingSelection("inherit"); @@ -1981,24 +1938,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <>
- {!projectExpanded && projectStatus ? (