From eaab02252c37c0d3bf4b583e7b540068fa43d5d3 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:32:48 -0300 Subject: [PATCH 01/15] feat(settings): add shared project defaults and scoped overrides Project settings move under Settings -> Projects with machine and project scopes. Machine defaults cover model, workspace, automatic pull, agent browser access and actions; a project override wins until reset. Pylon adaptations: keep project icon compatibility guards, rollback-aware startup tests and MCP fence/lifecycle tests; provide the orchestration projection infrastructure to ProviderService because rollback admission builds it before orchestration (otherwise project browser overrides could never resolve a thread's project); treat the machine default model like a project default for mobile unavailable-provider remediation; drop the provider-setup link that depends on unported upstream #9348. The WelcomeWizard hunk is deferred until onboarding lands. Adopted from 9f40b2f563c662b43887b11ff99c466fe871c1af (#9754) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadRouteScreen.tsx | 15 +- .../threads/new-task-flow-provider.tsx | 5 +- apps/server/src/orchestration/runtimeLayer.ts | 2 +- .../project/ProjectSetupScriptRunner.test.ts | 61 + .../src/project/ProjectSetupScriptRunner.ts | 22 +- .../provider/Layers/ProviderService.test.ts | 107 +- .../src/provider/Layers/ProviderService.ts | 22 +- apps/server/src/server.ts | 15 +- apps/server/src/serverRuntimeStartup.test.ts | 61 +- apps/server/src/serverRuntimeStartup.ts | 21 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 17 +- apps/web/src/components/ChatView.tsx | 89 +- apps/web/src/components/CommandPalette.tsx | 2 + .../src/components/chat/DraftHeroHeadline.tsx | 9 +- .../DesktopAppActivationCoordinator.tsx | 7 +- .../settings/IntegrationsSettings.test.tsx | 14 +- .../settings/IntegrationsSettings.tsx | 40 +- .../settings/ProjectActionsList.tsx | 69 ++ .../ProjectDefaultActionsSettings.tsx | 114 ++ .../settings/ProjectDefaultsSettings.tsx | 465 ++++++++ .../settings/ProjectSettingsPanel.tsx | 1058 ++++++++++------- .../components/settings/ProjectsSettings.tsx | 160 +++ .../components/settings/SettingsPanels.tsx | 51 +- .../settings/SettingsSidebarNav.tsx | 10 +- .../components/settings/settingsLayout.tsx | 10 +- .../src/components/settings/settingsSearch.ts | 15 +- apps/web/src/hooks/useHandleNewThread.test.ts | 26 +- apps/web/src/hooks/useHandleNewThread.ts | 30 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/projects.$projectKey.tsx | 10 +- apps/web/src/routes/settings.projects.tsx | 27 + docs/user/project-settings.md | 40 +- .../src/state/sharedSettings.test.ts | 16 +- .../src/state/sharedSettings.ts | 1 - packages/contracts/src/settings.ts | 37 +- packages/shared/src/projectScripts.ts | 22 +- packages/shared/src/serverSettings.test.ts | 168 +++ packages/shared/src/serverSettings.ts | 54 + 38 files changed, 2261 insertions(+), 652 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectActionsList.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectsSettings.tsx create mode 100644 apps/web/src/routes/settings.projects.tsx diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 56c55801fd..eed1a1344e 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -8,6 +8,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { + DEFAULT_SERVER_SETTINGS, EnvironmentId, ThreadId, type ProjectScript, @@ -29,7 +30,11 @@ import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -822,7 +827,12 @@ function ThreadRouteContent( mutationBlocked: rollbackActive, canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + projectScripts: selectedThreadProject + ? resolveProjectScripts( + routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedThreadProject, + ) + : [], terminalSessions: terminalMenuSessions, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, @@ -1054,6 +1064,7 @@ function ThreadRouteContent( <> {activeInspectorRenderer ? : null} , + settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provide(settings), ); describe("ProjectSetupScriptRunner", () => { + it.effect("runs the inherited machine setup action in the checkout's worktree", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-default-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toMatchObject({ status: "started", scriptId: "default-setup" }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + data: "npm install\r", + }); + }).pipe( + Effect.provide( + testLayer( + makeProject([]), + { open, write }, + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ); + }); + it.effect("returns no-script when no setup script exists", () => { const open = vi.fn(() => Effect.die("unexpected open")); const write = vi.fn(() => Effect.die("unexpected write")); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf4..6a79c853dc 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,9 @@ import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +11,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 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { status: "no-script", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 46d8778e04..53b1e55c58 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -24,6 +24,7 @@ import { EnvironmentId, EventId, MessageId, + OrchestrationThreadShell, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProjectId, ProviderDriverKind, @@ -92,6 +93,7 @@ import { makeAdapterRegistryMock, makeInstanceAdapterRegistryMock, } from "../testUtils/providerAdapterRegistryMock.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( @@ -6195,13 +6197,63 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { ); }); +const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); + describe("agent browser access", () => { const revokedThreads: Array = []; + const projectId = ProjectId.make("project-browser-access"); + + const makeBrowserAccessProjectionLayer = (threadId: ThreadId) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getPendingRequestActivities: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), + getThreadShellById: (requestedThreadId) => + Effect.gen(function* () { + assert.equal(requestedThreadId, threadId); + return Option.some( + yield* decodeBrowserAccessThreadShell({ + id: threadId, + projectId, + title: "Browser access test", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }), + ); + }).pipe(Effect.orDie), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); const makeAgentBrowserProviderLayer = ( enableAgentBrowserAccess: boolean, codex: ReturnType, options: NonNullable[0]>, + project?: { readonly threadId: ThreadId; readonly override?: boolean | undefined }, ) => { const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, @@ -6211,10 +6263,18 @@ describe("agent browser access", () => { Layer.provide(SqlitePersistenceMemory), ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const projectOverride = project?.override; return makeProviderServiceLive(options).pipe( Layer.provide(providerAdapterLayer), Layer.provideMerge(directoryLayer), - Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })), + Layer.provide(project ? makeBrowserAccessProjectionLayer(project.threadId) : Layer.empty), + Layer.provide( + ServerSettings.ServerSettingsService.layerTest({ + enableAgentBrowserAccess, + projectAgentBrowserAccessOverrides: + projectOverride === undefined ? {} : { [projectId]: projectOverride }, + }), + ), Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( @@ -6226,18 +6286,27 @@ describe("agent browser access", () => { ); }; - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => + const startSessionWith = ( + enableAgentBrowserAccess: boolean, + threadId: ThreadId, + projectOverride?: boolean, + ) => Effect.gen(function* () { const issued: Array = []; const codex = makeFakeCodexAdapter(); - const providerLayer = makeAgentBrowserProviderLayer(enableAgentBrowserAccess, codex, { - issueMcpCredential: (request) => - Effect.sync(() => { - issued.push(request.threadId); - return undefined; - }), - revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), - }); + const providerLayer = makeAgentBrowserProviderLayer( + enableAgentBrowserAccess, + codex, + { + issueMcpCredential: (request) => + Effect.sync(() => { + issued.push(request.threadId); + return undefined; + }), + revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), + }, + { threadId, override: projectOverride }, + ); yield* Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -6489,4 +6558,22 @@ describe("agent browser access", () => { }).pipe(Effect.provide(providerLayer)); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-off"); + revokedThreads.length = 0; + const issued = yield* startSessionWith(true, threadId, false); + assert.deepEqual(issued, []); + assert.deepEqual(revokedThreads, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("requests an MCP credential when the project overrides browser access to on", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-on"); + const issued = yield* startSessionWith(false, threadId, true); + assert.deepEqual(issued, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a2e969704c..55777367aa 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -57,6 +57,7 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -105,6 +106,7 @@ import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; import { RollbackSagaRepository } from "../../persistence/Services/RollbackSagas.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); /** How long a manual context compaction may run before ProviderService gives up on it. */ @@ -431,6 +433,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } }); const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = @@ -969,8 +974,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentBrowserAccess), + const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + function* (threadId: ThreadId) { + const settings = yield* serverSettings.getSettings; + if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { + return settings.enableAgentBrowserAccess; + } + // Provider-only runtimes may omit orchestration. An unresolved project + // must not bypass an explicit browser override. + if (Option.isNone(projectionQuery)) return false; + const thread = yield* projectionQuery.value.getThreadShellById(threadId); + if (Option.isNone(thread)) return false; + return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + }, Effect.catch((cause) => Effect.logWarning( "Could not read server settings; withholding agent browser access for this session.", @@ -986,7 +1002,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ) => Effect.gen(function* () { const fence = adapter.runtimeFence; - if (!(yield* agentBrowserAccessEnabled)) { + if (!(yield* agentBrowserAccessEnabled(threadId))) { yield* clearMcpSession(threadId, fence); return undefined; } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fabb8a4266..d12af12ca8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -124,7 +124,10 @@ import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { + OrchestrationInfrastructureLayerLive, + OrchestrationLayerLive, +} from "./orchestration/runtimeLayer.ts"; import * as RollbackAdmission from "./rollback/RollbackAdmission.ts"; import * as RollbackWorkspace from "./rollback/RollbackWorkspace.ts"; import { @@ -309,6 +312,10 @@ const ProviderLayerLive = ProviderAuthServiceLive.pipe( Layer.provide(ProviderAdapterRegistryLive), Layer.provideMerge(ProviderSessionDirectoryLayerLive), Layer.provideMerge(RollbackSagaRepositoryLive), + // Project browser-access overrides resolve a thread's project through the + // optional projection query. Rollback admission builds this layer before + // orchestration, so provide the shared (memoized) projection infrastructure. + Layer.provide(OrchestrationInfrastructureLayerLive), ), ), ); @@ -340,7 +347,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunner.layer), + Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -377,7 +384,9 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), - Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)), + ), ), ), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 3266ba4523..24e151312d 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -14,6 +14,7 @@ import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => @@ -40,7 +41,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; const project = (workspaceRoot: string, autoPull = true) => - ({ workspaceRoot, autoPull }) as never; + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; yield* ServerRuntimeStartup.autoPullProjects([ project("/clean"), @@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); + + pulled.length = 0; + yield* ServerRuntimeStartup.autoPullProjects( + [project("/inherited", false), project("/opted-out"), project("/dirty", false)], + { + defaultAutoPull: true, + projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, + }, + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + assert.deepStrictEqual(pulled, ["/inherited"]); }), ); @@ -124,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -188,8 +200,19 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }); }); -it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => +it.effect.each([ + { existing: false, machineModel: null, projectModel: null }, + { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, +])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => Effect.gen(function* () { + const machineSelection = machineModel + ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } + : null; + const projectSelection = projectModel + ? { instanceId: ProviderInstanceId.make("codex"), model: projectModel } + : null; const dispatchCalls = yield* Ref.make< ReadonlyArray<{ readonly type: string; @@ -198,6 +221,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + existing + ? Option.some({ + id: ProjectId.make("existing-project"), + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: projectSelection, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }) + : Option.none(), + ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), @@ -244,13 +282,17 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), - ["project.create", "thread.create"], + existing ? ["thread.create"] : ["project.create", "thread.create"], + ); + if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); + assert.deepStrictEqual( + commands.at(-1)?.modelSelection, + projectSelection ?? + machineSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, ); - assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual(commands[1]?.modelSelection, { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); }), ); @@ -266,6 +308,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa const dispatchCalls = yield* Ref.make>([]); const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cf0c914dd8..8070e8a3ce 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -9,6 +10,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapThreadId: ThreadId | undefined; if (serverConfig.autoBootstrapProjectFromCwd) { + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + const defaultModelSelection = + settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); yield* Effect.gen(function* () { const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( serverConfig.cwd, @@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); + nextThreadModelSelection = defaultModelSelection; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.make(yield* randomUUID), @@ -221,7 +226,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { } else { nextProjectId = existingProject.value.id; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); + existingProject.value.defaultModelSelection ?? defaultModelSelection; } const existingThreadId = @@ -743,12 +748,16 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, + settings: Pick< + typeof DEFAULT_SERVER_SETTINGS, + "defaultAutoPull" | "projectAutoPullOverrides" + > = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => project.autoPull === true) + .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), ]; @@ -819,7 +828,11 @@ export const make = (options?: StartupOptions) => const reactorScope = yield* Scope.make("sequential"); const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( - Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), + Effect.flatMap((snapshot) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => autoPullProjects(snapshot.projects, settings)), + ), + ), Effect.catch((cause) => Effect.logWarning("Failed to load projects for automatic pull", { cause }), ), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 011af8e3b7..d6bc9cfcb0 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,10 +22,12 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; return { - isEnabled: (cwd: string) => - snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ), + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + }, + Effect.orElseSucceed(() => false), + ), }; }), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d2c4b6646..fc0672d40c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -66,7 +66,11 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; @@ -321,7 +325,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -1451,7 +1454,9 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); @@ -1598,9 +1603,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - // New-thread defaults live in the primary environment's settings.json (the - // settings UI never writes to remote environments), so read them from the - // primary server rather than the thread's environment. const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -1893,10 +1895,17 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + fallbackDraftProject?.defaultModelSelection ?? + settings.defaultModelSelection ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], + [ + draftThread, + fallbackDraftProject?.defaultModelSelection, + settings.defaultModelSelection, + threadId, + ], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -2127,6 +2136,12 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + const activeProjectScripts = useMemo( + () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), + [activeProject, settings], + ); + const activeProjectDefaultModelSelection = + activeProject?.defaultModelSelection ?? settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2175,8 +2190,8 @@ export default function ChatView(props: ChatViewProps) { [activeProjectKey], ); const configuredPreviewUrls = useMemo( - () => getConfiguredPreviewUrls(activeProject?.scripts), - [activeProject?.scripts], + () => getConfiguredPreviewUrls(activeProjectScripts), + [activeProjectScripts], ); useEffect(() => { @@ -2414,7 +2429,7 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, @@ -2673,12 +2688,12 @@ export default function ChatView(props: ChatViewProps) { draftActiveProvider: composerActiveProvider, sessionInstanceId: activeThread?.session?.providerInstanceId, threadInstanceId: activeThread?.modelSelection?.instanceId, - projectInstanceId: activeProject?.defaultModelSelection?.instanceId, + projectInstanceId: activeProjectDefaultModelSelection?.instanceId, lockedProvider, nowMs: Date.now(), }), [ - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, activeThread?.modelSelection?.instanceId, activeThread?.session?.providerInstanceId, composerActiveProvider, @@ -3903,11 +3918,14 @@ export default function ChatView(props: ChatViewProps) { keybindingCommand: KeybindingCommand | null; }): Promise> => { const updateResult = mapAtomCommandResult( - await updateProject({ + await updateProjectScriptSettings({ environmentId, input: { - projectId: input.projectId, - scripts: input.nextScripts, + patch: { + projectScriptOverrides: { + [input.projectId]: input.nextScripts, + }, + }, }, }), () => undefined, @@ -3932,7 +3950,7 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, updateProjectScriptSettings, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3941,28 +3959,28 @@ export default function ChatView(props: ChatViewProps) { } const nextId = nextProjectScriptId( input.name, - activeProject.scripts.map((script) => script.id), + activeProjectScripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ - ...activeProject.scripts.map((script) => + ...activeProjectScripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] - : [...activeProject.scripts, nextScript]; + : [...activeProjectScripts, nextScript]; return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const updateProjectScript = useCallback( async ( @@ -3972,13 +3990,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeProject) { return AsyncResult.success(undefined); } - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + const existingScript = activeProjectScripts.find((script) => script.id === scriptId); if (!existingScript) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProject.scripts.map((script) => + const nextScripts = activeProjectScripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate @@ -3989,27 +4007,27 @@ export default function ChatView(props: ChatViewProps) { return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const deleteProjectScript = useCallback( async (scriptId: string): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; const result = await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), @@ -4031,7 +4049,7 @@ export default function ChatView(props: ChatViewProps) { } return result; }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const handleRuntimeModeChange = useCallback( @@ -6338,7 +6356,7 @@ export default function ChatView(props: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); + const script = activeProjectScripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -6349,6 +6367,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeRightPanelSurface, + activeProjectScripts, addTerminalSurface, activeThreadRef, activeThreadPinned, @@ -7045,7 +7064,7 @@ export default function ChatView(props: ChatViewProps) { const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, - ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); @@ -8991,7 +9010,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectFaviconPath={activeProject?.faviconPath ?? null} activeProjectIcon={activeProject?.projectIcon ?? null} openInCwd={gitCwd} - activeProjectScripts={activeProject?.scripts} + activeProjectScripts={activeProjectScripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } @@ -9260,9 +9279,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} - activeProjectDefaultModelSelection={ - activeProject?.defaultModelSelection - } + activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 0c306571ec..bb6166d558 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1848,6 +1848,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index e17d3d7380..c671431749 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -150,8 +150,13 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - if (project.defaultModelSelection) { - setModelSelection(draftId, project.defaultModelSelection, { + const defaultModelSelection = + project.defaultModelSelection ?? + environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index e97a46a2a2..3e941e69dd 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot) => { const projectId = newProjectId(); - const providers = - primaryEnvironment?.environmentId === environmentId - ? (primaryEnvironment.serverConfig?.providers ?? []) - : []; const result = await createProject({ environmentId, input: { @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, createWorkspaceRootIfMissing: false, - defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + defaultModelSelection: null, }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 1a8dc7d6aa..5d185fee58 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -1,4 +1,10 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; import { act, StrictMode, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -44,13 +50,19 @@ afterEach(async () => { }); async function openSettings() { + const router = createRouter({ + routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), + history: createMemoryHistory(), + }); + await router.load(); await act(() => { renderer = create( - + , ); }); + expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); } describe("Integrations browser discovery", () => { diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 11c3bd6156..2ea293280e 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -20,7 +20,6 @@ import { DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, - DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, @@ -35,6 +34,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; +import { Link } from "@tanstack/react-router"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useCallback, useRef, useState, type ReactNode } from "react"; @@ -86,7 +86,6 @@ import { persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, - usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) } function AgentBrowserAccessSetting() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - return ( - updateSettings({ - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, - }) - } - /> - ) : null - } + description="Choose whether agents can use the preview browser for all projects or a specific project." control={ - - updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) + } /> ); diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx new file mode 100644 index 0000000000..1794a5fdaa --- /dev/null +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -0,0 +1,69 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { SettingsIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { commandForProjectScript } from "../../projectScripts"; +import { ScriptIcon } from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; + +export function ProjectActionsList({ + scripts, + keybindings, + disabled, + onEdit, +}: { + scripts: readonly ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + disabled: boolean; + onEdit: (script: ProjectScript) => void; +}) { + if (scripts.length === 0) + return ( +

+ No actions configured. +

+ ); + return scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id)); + return ( + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} + + } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> + ); + }); +} diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx new file mode 100644 index 0000000000..4385a5901b --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useEnvironments } from "../../state/environments"; +import { + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + type ProjectScriptEditorRequest, +} from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { useProjectScriptSettings } from "./ProjectSettingsPanel"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ProjectDefaultActionsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const targets = environments.filter( + (environment) => + (environmentId === null || environment.environmentId === environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + const representative = targets[0]?.serverConfig; + const scripts = representative?.settings.defaultProjectScripts ?? []; + const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const mixed = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== + JSON.stringify(scripts), + ); + const [request, setRequest] = useState(null); + const { saving, persist, submit } = useProjectScriptSettings( + targets.flatMap(({ environmentId, serverConfig }) => + serverConfig + ? [ + { + environmentId, + settings: serverConfig.settings, + keybindings: serverConfig.keybindings, + }, + ] + : [], + ), + ); + + return ( + + + Import scripts + + } + /> + (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, + ) ? ( + void persist(() => [])} + /> + ) : null + } + control={ + + } + /> + {mixed ? ( + + ) : ( + setRequest(editorRequestForScript(script, keybindings))} + /> + )} + + void persist((current) => current.filter((script) => script.id !== id), id, null) + } + onClose={() => setRequest(null)} + /> + + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx new file mode 100644 index 0000000000..1f2ccb974a --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -0,0 +1,465 @@ +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProviderInstanceId, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; +import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; +import { searchableSetting } from "./settingsSearch"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +/** Defaults are written only to the machines selected on the projects settings page. */ +export function ProjectDefaultsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); + const updateSettings = useAtomCommand( + serverEnvironment.updateSettings, + "project defaults update", + ); + const savingRef = useRef(new Set()); + const [saving, setSaving] = useState>(new Set()); + const scoped = environments.filter( + (environment) => environmentId === null || environment.environmentId === environmentId, + ); + const targets = scoped.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const representative = + targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; + const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; + const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const settings = { ...serverSettings, ...clientSettings }; + const storedSelection = serverSettings.defaultModelSelection; + const selection = resolveDefaultProviderModelSelection(providers, storedSelection); + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + ); + const modelOptions = getCustomModelOptionsByInstance( + settings, + providers, + selection?.instanceId, + selection?.model, + ); + const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const mixedModel = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== + JSON.stringify(storedSelection), + ); + const mixedWorkspace = targets.some( + (target) => + target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, + ); + const mixedBrowser = targets.some( + (target) => + target.serverConfig?.settings.enableAgentBrowserAccess !== + serverSettings.enableAgentBrowserAccess, + ); + const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); + const mixedAutoPull = targets.some( + (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, + ); + + function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { + const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const entry = applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === instanceId); + const options = getCustomModelOptionsByInstance( + { ...config.settings, ...clientSettings }, + config.providers, + ).get(instanceId); + if ( + !entry?.enabled || + !entry.isAvailable || + entry.driverKind !== sourceEntry?.driverKind || + !options?.some((option) => option.slug === model && !option.isUnavailable) + ) { + return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`; + } + } + return null; + } + + async function save(patch: ServerSettingsPatch) { + const keys = Object.keys(patch); + if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; + const nextModel = patch.defaultModelSelection; + const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; + if (reason) { + toastManager.add({ type: "error", title: "Default model not saved", description: reason }); + return; + } + for (const key of keys) savingRef.current.add(key); + setSaving(new Set(savingRef.current)); + try { + const results = await Promise.all( + targets.map((target) => + updateSettings({ environmentId: target.environmentId, input: { patch } }), + ), + ); + const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); + if (failedTargets.length > 0) { + toastManager.add({ + type: "error", + title: "Project defaults not saved on every machine", + description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, + }); + } + } finally { + for (const key of keys) savingRef.current.delete(key); + setSaving(new Set(savingRef.current)); + } + } + + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); + return ( + + + + } + /> + + + + + } + /> + {scoped.length > targets.length || targets.length === 0 ? ( +

+ {targets.length === 0 + ? "Connect a machine to change its project defaults." + : "Changes apply to connected machines only. Offline machines keep their current defaults."} +

+ ) : null} + setModel(null)} + /> + ) : null + } + control={ + selection && activeEntry ? ( +
+ + setModel(createModelSelection(instanceId, model)) + } + /> + {!mixedModel ? ( + {}} + modelOptions={selection.options ?? []} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(options) => + setModel(createModelSelection(selection.instanceId, selection.model, options)) + } + /> + ) : null} +
+ ) : ( + No providers available + ) + } + /> + + void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) + } + /> + ) : null + } + control={ + + } + /> + void save({ defaultAutoPull: false })} + /> + ) : null + } + control={ + void save({ defaultAutoPull: enabled })} + /> + } + /> + + void save({ + enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, + }) + } + /> + ) : null + } + control={ + + } + /> +
+ + + + + + + } + /> + + void updateClientSettings({ + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + Remove checkout + + } + /> + + + + + + Remove project + + } + /> + +
+ ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index b75b88f853..c9d92a359d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,52 +12,53 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + type EnvironmentId, + type ModelSelection, + type ProjectIconOverride, + type ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ServerSettings, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { + projectScriptsInheritDefaults, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; +import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; -import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; -import { isElectron } from "../../env"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, - usePrimarySettings, } from "../../hooks/useSettings"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; -import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; -import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; -import { readLocalApi } from "../../localApi"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { isElectron } from "../../env"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; -import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; +import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { readLocalApi } from "../../localApi"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -98,16 +99,8 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -127,14 +120,14 @@ const ProjectIconPickerDialog = lazy(() => })), ); -const PROJECT_GROUPING_MODE_LABELS: Record = { +export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ -function useSettingsProjectGroups(): SidebarProjectSnapshot[] { +export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - event.preventDefault(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - navigateBackWithinApp(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateBackWithinApp]); - - return ( - -
- - - - -
-
- ); -} - -function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { - const groups = useSettingsProjectGroups(); - const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - - ); -} - -function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +export function ProjectSettingsPanel({ + projectKey, + environmentId = null, +}: { + projectKey: string; + environmentId?: EnvironmentId | null; +}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const members = useMemo( + () => + selected?.memberProjects.filter( + (member) => environmentId === null || member.environmentId === environmentId, + ) ?? [], + [selected, environmentId], + ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. - const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); + const lastSelectionRef = useRef<{ + key: string; + environmentId: EnvironmentId | null; + memberKeys: string[]; + } | null>(null); useEffect(() => { - if (!selected) return; + if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, - memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), + environmentId, + memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected]); + }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selected !== null) return; + if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey) return; + if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ - to: "/projects/$projectKey", - params: { projectKey: successor.projectKey }, + to: "/settings/projects", + search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected]); + }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( @@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { ); } - return ; + if (members.length === 0) + return ( +

+ This project has no checkout on this machine. +

+ ); + const scopedGroup = { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + }; + return ( + + ); +} + +function reportScriptFailure(result: AtomCommandResult) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to save project actions", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return mapAtomCommandResult(result, () => undefined); } -function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +export function useProjectScriptSettings( + targets: readonly { + environmentId: EnvironmentId; + settings: ServerSettings; + keybindings: ResolvedKeybindingsConfig; + project?: { id: ProjectId; scripts: readonly ProjectScript[] }; + }[], +) { + const projects = useProjects(); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); + const upsertKeybinding = useAtomCommand( + serverEnvironment.upsertKeybinding, + "action shortcut update", + ); + const removeKeybinding = useAtomCommand( + serverEnvironment.removeKeybinding, + "action shortcut removal", + ); + + async function persist( + transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, + scriptId?: string, + keybinding?: string | null, + ): Promise> { + if (savingRef.current || targets.length === 0) { + const message = "No available machine, or another action change is saving."; + toastManager.add({ type: "error", title: "Actions not saved", description: message }); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + savingRef.current = true; + setSaving(true); + try { + for (const { environmentId, settings, keybindings, project } of targets) { + const current = project + ? resolveProjectScripts(settings, project) + : settings.defaultProjectScripts; + const nextScripts = transform(current); + const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; + const result = await updateSettings({ + environmentId, + input: { + patch: project + ? { projectScriptOverrides: { [project.id]: nextScripts } } + : { defaultProjectScripts: nextScripts ?? [] }, + }, + }); + if (result._tag === "Failure") return reportScriptFailure(result); + if (!isElectron) continue; + const changedIds = scriptId + ? [scriptId] + : current + .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) + .map((script) => script.id); + for (const id of changedIds) { + const command = commandForProjectScript(id); + const previousValue = keybindingValueForCommand(keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + const retainedElsewhere = + !nextScripts?.some((script) => script.id === id) && + ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || + Object.entries(settings.projectScriptOverrides).some( + ([projectId, scripts]) => + projectId !== project?.id && scripts?.some((script) => script.id === id), + ) || + projects.some( + (other) => + other.environmentId === environmentId && + other.id !== project?.id && + (project ? resolveProjectScripts(settings, other) : other.scripts).some( + (script) => script.id === id, + ), + )); + const bindingResult = next + ? await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }) + : previous && !retainedElsewhere + ? await removeKeybinding({ environmentId, input: previous }) + : null; + if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); + } + } + return AsyncResult.success(undefined); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + function submit(scriptId: string | null, input: NewProjectScriptInput) { + const existingIds = [ + ...projects.flatMap((project) => project.scripts.map((script) => script.id)), + ...targets.flatMap(({ settings, project }) => + [ + ...settings.defaultProjectScripts, + ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), + ...(project?.scripts ?? []), + ].map((script) => script.id), + ), + ]; + const id = scriptId ?? nextProjectScriptId(input.name, existingIds); + const next = buildProjectScript(id, input); + return persist( + (current) => { + const updated = current.map((script) => + script.id === id + ? next + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + return scriptId === null ? [...updated, next] : updated; + }, + id, + input.keybinding, + ); + } + + return { saving, persist, submit }; +} + +function ProjectDetail({ + group, + hasOtherMembers, +}: { + group: SidebarProjectSnapshot; + hasOtherMembers: boolean; +}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const representative = group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, + (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; - const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. @@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); - const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { - reportFailure: false, + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); + const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); + const savingBrowserAccessRef = useRef(false); + const browserOverrides = group.memberProjects.map( + (member) => + environmentById.get(member.environmentId)?.serverConfig?.settings + .projectAgentBrowserAccessOverrides[member.id], + ); + const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; + const browserMixed = group.memberProjects.some((member, index) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; + return ( + browserOverrides[index] !== browserOverride || + (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== + (browserOverride ?? projectSettings.enableAgentBrowserAccess) + ); }); + const setBooleanOverride = async ( + key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", + enabled: boolean | undefined, + ) => { + if (savingBrowserAccessRef.current) return; + savingBrowserAccessRef.current = true; + setSavingBrowserAccess(true); + try { + const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); + for (const environmentId of environmentIds) { + const environment = environmentById.get(environmentId); + if (!environment?.serverConfig || environment.connection.phase !== "connected") { + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: `Connect ${environment?.label ?? "this machine"} and try again.`, + }); + return; + } + } + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of environmentIds) { + const overrides = Object.fromEntries( + group.memberProjects + .filter((member) => member.environmentId === environmentId) + .map((member) => [member.id, enabled ?? null]), + ); + const result = await updateServerSettings({ + environmentId, + input: { patch: { [key]: overrides } }, + }); + if (result._tag === "Failure") { + reportFailure( + `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, + mapAtomCommandResult(result, () => undefined), + ); + return; + } + } + } finally { + savingBrowserAccessRef.current = false; + setSavingBrowserAccess(false); + } + }; + const setBrowserAccess = (enabled: boolean | undefined) => + setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); - const { copyToClipboard: copyPathToClipboard } = 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 faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -359,14 +497,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ? window.desktopBridge?.pickProjectFavicon : undefined; - const threadCountByMember = useMemo(() => { - const counts = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -441,7 +571,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const resolvedSelection = resolveDefaultProviderModelSelection( + serverProviders, + storedSelection ?? projectSettings.defaultModelSelection, + ); + const mixedModel = group.memberProjects.some((member) => { + const config = environmentById.get(member.environmentId)?.serverConfig; + return ( + !Equal.equals(member.defaultModelSelection, storedSelection) || + (config !== null && + config !== undefined && + environmentById.get(representative.environmentId)?.serverConfig != null && + JSON.stringify( + resolveDefaultProviderModelSelection( + config.providers, + member.defaultModelSelection ?? config.settings.defaultModelSelection, + ), + ) !== JSON.stringify(resolvedSelection)) + ); + }); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -465,14 +613,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); - const setDefaultModel = useCallback( - (selection: ModelSelection | null) => - void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), - [updateAllMembers], - ); + const setDefaultModel = (selection: ModelSelection | null) => { + if (selection !== null) { + for (const member of group.memberProjects) { + const environment = environmentById.get(member.environmentId); + const config = environment?.serverConfig; + const entry = config + ? applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === selection.instanceId) + : undefined; + const options = config + ? getCustomModelOptionsByInstance( + { ...projectSettings, ...config.settings }, + config.providers, + ).get(selection.instanceId) + : undefined; + if ( + !entry?.enabled || + !entry.isAvailable || + !options?.some((model) => model.slug === selection.model && !model.isUnavailable) + ) { + toastManager.add({ + type: "warning", + title: "Project model not saved", + description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, + }); + return; + } + } + } + void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); + }; // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; + const mixedWorkspace = group.memberProjects.some( + (member) => member.defaultThreadEnvMode !== storedEnvMode, + ); const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( @@ -482,12 +661,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - const autoPull = representative.autoPull ?? false; - const setAutoPull = useCallback( - (enabled: boolean) => - void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), - [updateAllMembers], + const autoPull = resolveProjectAutoPull( + projectSettings, + representative.id, + representative.autoPull, + ); + const autoPullOverridden = group.memberProjects.some( + (member) => + member.autoPull || + environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ + member.id + ] !== undefined, ); + const mixedAutoPull = group.memberProjects.some((member) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; + }); + const setAutoPull = (enabled: boolean | undefined) => + setBooleanOverride("projectAutoPullOverrides", enabled); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -517,27 +708,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // ----- checkout selection and scripts ----- - const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); - const selectedCheckout = - group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? - representative; + const hasMultipleCheckouts = group.memberProjects.length > 1; + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); + const selectedCheckoutMatch = group.memberProjects.find( + (member) => member.physicalProjectKey === selectedCheckoutKey, + ); + const selectedCheckout = selectedCheckoutMatch ?? representative; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const scripts = selectedCheckout.scripts; + const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); + const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); - // Script writes replace the whole array, so two overlapping writes computed - // from the same snapshot would drop each other's changes. One at a time. - const [isSavingScripts, setIsSavingScripts] = useState(false); - const savingScriptsRef = useRef(false); + const { + saving: isSavingScripts, + persist: persistScripts, + submit: submitScript, + } = useProjectScriptSettings([ + { + environmentId: selectedCheckout.environmentId, + settings: scriptSettings, + keybindings, + project: selectedCheckout, + }, + ]); const t3File = useT3ProjectFileState( selectedCheckout.environmentId, selectedCheckout.workspaceRoot, ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. - const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; + const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => @@ -552,135 +755,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [scripts, t3File.scripts], ); - const persistScripts = useCallback( - async ( - nextScripts: ReadonlyArray>, - keybinding: string | null | undefined, - keybindingCommand: ReturnType, - ): Promise> => { - if (savingScriptsRef.current) { - return AsyncResult.failure( - Cause.fail(new Error("Another script change is still saving. Try again.")), - ); - } - savingScriptsRef.current = true; - setIsSavingScripts(true); - try { - // Captured before the write so a cleared or deleted binding can be - // removed from the keybindings config afterwards. - const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = mapAtomCommandResult( - await updateProject({ - environmentId: selectedCheckout.environmentId, - input: { projectId: selectedCheckout.id, scripts: nextScripts }, - }), - () => undefined, - ); - if (updateResult._tag === "Failure") { - reportFailure("Failed to save scripts", updateResult); - return updateResult; - } - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); - if (!isElectron) return updateResult; - const environmentIds = [selectedCheckout.environmentId]; - const previousTarget = previousKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: previousKeybinding, - command: keybindingCommand, - }) - : null; - if (keybindingRule) { - // `replace` swaps the command's previous rule instead of appending a - // second one that would keep the old shortcut alive. - const input = - previousTarget && previousTarget.key !== keybindingRule.key - ? { ...keybindingRule, replace: previousTarget } - : keybindingRule; - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await upsertKeybinding({ environmentId, input }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to save keybinding", result); - return result; - } - } - } else if (previousTarget) { - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await removeKeybinding({ environmentId, input: previousTarget }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to remove keybinding", result); - return result; - } - } - } - return updateResult; - } finally { - savingScriptsRef.current = false; - setIsSavingScripts(false); - } - }, - [ - keybindings, - removeKeybinding, - reportFailure, - selectedCheckout.environmentId, - selectedCheckout.id, - updateProject, - upsertKeybinding, - ], - ); - - const submitScript = useCallback( - async ( - scriptId: string | null, - input: NewProjectScriptInput, - ): Promise> => { - if (scriptId === null) { - const nextId = nextProjectScriptId( - input.name, - scripts.map((script) => script.id), - ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...scripts, nextScript]; - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); - } - - const updatedScript = buildProjectScript(scriptId, input); - const nextScripts = scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); - - const deleteScript = useCallback( - (scriptId: string) => { - const nextScripts = scripts.filter((script) => script.id !== scriptId); - void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); + const deleteScript = (scriptId: string) => + void persistScripts( + (current) => current.filter((script) => script.id !== scriptId), + scriptId, + null, + ); const importFileScript = useCallback( async (fileScript: T3ProjectFileScript) => { @@ -703,7 +783,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [submitScript, setEditorRequest], ); // ----- checkouts ----- @@ -731,14 +811,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; + const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -752,7 +833,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), - isWholeGroup + isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", @@ -794,33 +875,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { draftStore.clearProjectDraftThreadId(projectRef); } - // The project's settings page just deleted itself; there is no projects - // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/", replace: true }); + if (hasOtherMembers) { + void navigate({ + to: "/settings/projects", + search: { project: group.projectKey, machine: undefined }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } } }, [ deleteProject, group.displayName, group.memberProjects.length, + group.projectKey, + hasOtherMembers, navigate, reportFailure, threads, ], ); - const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; - const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; + const checkoutLabel = (member: SidebarProjectGroupMember) => { + const label = member.environmentLabel ?? "This machine"; + return group.memberProjects.some( + (other) => + other.physicalProjectKey !== member.physicalProjectKey && + (other.environmentLabel ?? "This machine") === label, + ) + ? `${label} · ${member.workspaceRoot}` + : label; + }; + const selectedCheckoutLabel = checkoutLabel(selectedCheckout); return ( <> - - + + member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null @@ -956,11 +1066,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null @@ -1000,79 +1122,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + autoPullOverridden ? ( + void setAutoPull(undefined)} + /> ) : null } control={ void setAutoPull(enabled)} /> } /> + value !== undefined) ? ( + void setBrowserAccess(undefined)} + /> + ) : null + } + control={ + + } + /> - setSelectedCheckoutKey(String(value))} - > - - {selectedCheckoutLabel} - - - {group.memberProjects.map((member) => ( - - {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} - - ))} - - - } - > -
-
- - - copyPathToClipboard(selectedCheckout.workspaceRoot, { - path: selectedCheckout.workspaceRoot, - }) - } - > - - {selectedCheckout.workspaceRoot} - - - - } - /> - Copy path - -
- {selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} -
-
-
+ + {hasMultipleCheckouts ? ( + { + if (value) setSelectedCheckoutKey(value); + }} + > + + {selectedCheckoutLabel} + + + {group.memberProjects.map((member) => ( + + + {checkoutLabel(member)} + + + ))} + + + } + /> + ) : null} updateGroupingPreference(selectedCheckout, "inherit")} + /> + ) : null + } control={ { + if (next) onChange(next === "all" ? null : next); + }} + > + + + {value === null ? allIcon : selected?.icon} + + {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} + + + + + + + {allIcon}All {label}s + + + {options.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + + ); +} + +export function ProjectsSettings({ + projectKey, + machineId, + onScopeChange, +}: { + projectKey: string | null; + machineId: string | null; + onScopeChange: (project: string | null, machine: string | null) => void; +}) { + const groups = useSettingsProjectGroups(); + const { environments } = useEnvironments(); + const machine = environments.find((environment) => environment.environmentId === machineId); + const machineOptions = environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + icon: ( + + ), + })); + return ( +
+
+ +
+ {environments.length > 3 ? ( + onScopeChange(projectKey, value)} + /> + ) : ( + { + const value = next[0]; + if (value) onScopeChange(projectKey, value === "all" ? null : value); + }} + > + All machines + {machineOptions.map((option) => ( + + {option.icon} + {option.label} + + ))} + + )} +
+ ({ + value: group.projectKey, + label: group.displayName, + icon: ( + + ), + }))} + onChange={(value) => onScopeChange(value, machineId)} + /> +
+
+
+
+ {machineId !== null && !machine ? ( +

This machine is no longer available.

+ ) : projectKey === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a96fd2f099..55c741b5f5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2556,55 +2556,24 @@ export function GeneralSettingsPanel() { - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } + description="Choose the default model and workspace for all projects or a specific project." control={ - + Project settings + } /> = { "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, @@ -165,12 +167,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; - if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { + if ( + item.to !== "/settings/projects" && + pathname === item.to && + currentHash.replace(/^#/, "") === targetId + ) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: targetId, replace: true, hashScrollIntoView: false, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index fc07cf2f7f..2466073407 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -281,7 +281,11 @@ export function SettingsRow({ ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" - className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} + className={cn( + "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + children ? "pt-3 pb-1" : "py-3", + className, + )} >
@@ -317,10 +321,12 @@ export function SettingsRow({ export function SettingResetButton({ label, + tooltip = "Reset to default", disabled = false, onClick, }: { label: string; + tooltip?: string; disabled?: boolean; onClick: () => void; }) { @@ -342,7 +348,7 @@ export function SettingResetButton({ } /> - Reset to default + {tooltip} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 3ebfdad2d0..4b5a0a9048 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -2,6 +2,7 @@ import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; export type SettingsPath = + | "/settings/projects" | "/settings/general" | "/settings/appearance" | "/settings/keybindings" @@ -49,6 +50,7 @@ export interface SettingsSearchAvailability { export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", "/settings/appearance": "Appearance", + "/settings/projects": "Projects", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", "/settings/integrations": "Integrations", @@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "project-defaults", + title: "Project defaults and overrides", + to: "/settings/projects", + searchTerms: [ + "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", + ], + }, { id: "color-scheme", title: "Color scheme", @@ -239,14 +249,13 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "new-threads", title: "New threads", - to: "/settings/general", + to: "/settings/projects", searchTerms: ["default workspace mode draft local worktree"], }, { id: "start-from-origin", title: "Start from origin", to: "/settings/general", - targetId: "new-threads", searchTerms: ["new worktrees latest matching remote branch local"], }, { @@ -343,7 +352,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "agent-browser-access", title: "Agent browser access", - to: "/settings/integrations", + to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 91b757f51e..afd503e63c 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -49,14 +49,31 @@ const testState = vi.hoisted(() => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), + useAtomValue: (atom: unknown) => + atom === "primary-settings" + ? { newWorktreesStartFromOrigin: false } + : new Map([ + [ + "environment-ssh", + { + settings: { + defaultThreadEnvMode: "local", + newWorktreesStartFromOrigin: false, + defaultModelSelection: null, + }, + }, + ], + ]), })); vi.mock("@t3tools/client-runtime/environment", () => ({ scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/contracts", () => ({ + DEFAULT_RUNTIME_MODE: "default", + DEFAULT_SERVER_SETTINGS: {}, +})); vi.mock("@t3tools/shared/threadEnvMode", () => ({ resolveDefaultThreadEnvMode: (input: { readonly projectFile: "local" | "worktree" | null; @@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useThread: () => null, })); -vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../state/server", () => ({ + environmentServerConfigsAtom: {}, + primaryServerSettingsAtom: "primary-settings", +})); vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c26b25d131..78dfc1b13f 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,7 +4,12 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, + type ThreadId, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -30,7 +35,7 @@ import { resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; -import { primaryServerSettingsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - // New-thread defaults are a user preference, and the settings UI only ever - // edits the primary environment's settings.json. Reading the target - // environment's own settings here would silently reset remote projects to - // the decoded defaults ("local" mode, current branch), since nothing can - // set those values on a remote server. + const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); @@ -83,6 +84,8 @@ export function useNewThreadHandler() { // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); + const targetServerSettings = + environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -138,7 +141,8 @@ export function useNewThreadHandler() { ); const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: project?.defaultModelSelection ?? null, + projectDefaultSelection: + project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null, carrySelection: carryModelSelection, carrySourceDraftId: currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, @@ -157,7 +161,7 @@ export function useNewThreadHandler() { project.workspaceRoot, ) : null, - globalDefault: primaryServerSettings.defaultThreadEnvMode, + globalDefault: targetServerSettings.defaultThreadEnvMode, }); }; const logicalProjectKey = project @@ -429,7 +433,13 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], + [ + environmentServerConfigs, + getCurrentRouteTarget, + primaryServerSettings.newWorktreesStartFromOrigin, + projectGroupingSettings, + router, + ], ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace68..c0f8ef4d76 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -69,6 +70,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -147,6 +153,7 @@ export interface FileRoutesByFullPath { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -167,6 +174,7 @@ export interface FileRoutesByTo { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -190,6 +198,7 @@ export interface FileRoutesById { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -214,6 +223,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -234,6 +244,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' @@ -256,6 +267,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -331,6 +343,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/projects': { + id: '/settings/projects' + path: '/projects' + fullPath: '/settings/projects' + preLoaderRoute: typeof SettingsProjectsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -442,6 +461,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -454,6 +474,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx index 6ae03719c0..d636c0a953 100644 --- a/apps/web/src/routes/projects.$projectKey.tsx +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -1,15 +1,17 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; - export const Route = createFileRoute("/projects/$projectKey")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, params }) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" ) { throw redirect({ to: "/pair", replace: true }); } + throw redirect({ + to: "/settings/projects", + search: { project: params.projectKey, machine: undefined }, + replace: true, + }); }, - component: () => , }); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx new file mode 100644 index 0000000000..fa79f46fbb --- /dev/null +++ b/apps/web/src/routes/settings.projects.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ProjectsSettings } from "../components/settings/ProjectsSettings"; + +export const Route = createFileRoute("/settings/projects")({ + validateSearch: (search: Record) => ({ + project: typeof search.project === "string" ? search.project : undefined, + machine: typeof search.machine === "string" ? search.machine : undefined, + }), + component: ProjectsRoute, +}); + +function ProjectsRoute() { + const { project, machine } = Route.useSearch(); + const navigate = Route.useNavigate(); + return ( + { + void navigate({ + search: { project: project ?? undefined, machine: machine ?? undefined }, + replace: true, + }); + }} + /> + ); +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 1edcffdb92..4e2824025a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,15 +1,39 @@ -# Customize a project icon +# Project settings -On web and desktop, open the sidebar project filter and select the settings button beside a -project. In **Project icon**, select **Choose icon** for an icon and color or an emoji, or -**Choose file** for an image in the project. **Reset** returns to automatic selection. +Open **Settings → Projects**. The project and machine pickers start at **All projects** and +**All machines**. You can also open a project's settings from the sidebar project filter, the +chat header, or the command palette. + +With **All projects** selected, change the default model, workspace, automatic pull, agent browser +access, or actions for projects that inherit those values. Select an individual project to override +a default, and reset its row to inherit again. Changing a default keeps explicit project overrides. +A workspace preference in `t3.json` takes precedence over machine defaults when the project has no +workspace override of its own. + +Select a machine to limit edits to it. **All machines** writes defaults to connected machines; +offline machines keep their previous values. When selected machines or checkouts disagree, the row +says so. Browser access changes apply when an agent session next starts. + +Project grouping has a default for this client across machines, with individual checkout overrides. +Shared actions apply to projects that inherit them; editing a project's actions creates an +independent list for that checkout. Reset that list to use shared actions again. Existing project +actions are kept. + +Project names, icons, removal, and importing actions from a checkout's `t3.json` stay specific to a +project. When a project has several checkouts, the checkout picker chooses which actions and grouping +to edit. + +## Project icons + +Select a project, then in **Project icon** select **Choose icon** for an icon and color or an +emoji, or **Choose file** for an image in the project. **Reset** returns to automatic selection. Pylon checks `t3.json`, common favicon and app icon paths, and icon links in project HTML files. When no image is available, web and desktop choose an icon from the saved project name. The same name determines its icon in the sidebar, chat header, command palette, and pull request filters, even when those places display a different project label. -Icon and image choices apply to every checkout in a project group. All environments in the +Icon and image choices apply to the selected checkouts in a project group. All environments in the group must support saved icons before **Choose icon** is available. An older environment can still use its existing image picker. @@ -23,7 +47,11 @@ this cache and can clear it. ## Keep the default branch current -Turn on **Automatically pull** in a project's settings to keep its default-branch checkout current. +Turn on **Automatically pull** to keep a default-branch checkout current. Set it under **All +projects** to make it the default, or select a project to override the default for that project. +Projects that had automatic pull turned on before project defaults existed keep it on until you +change or reset them. + Pylon checks in the background and when the server starts. It uses the branch's configured upstream and only performs a fast-forward pull when the checkout has no working-tree changes, untracked files, or local commits. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index d713e8a63c..73c4aa563c 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -90,13 +90,19 @@ describe("splitSharedServerPatch", () => { sidebarAutoSettleOnMerge: false, continueThreadsAfterServerUpdate: true, enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: true, }); expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, continueThreadsAfterServerUpdate: true, + newWorktreesStartFromOrigin: true, + }); + expect(localPatch).toEqual({ + enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", }); - expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); @@ -106,7 +112,6 @@ describe("pickSharedServerSettings", () => { Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), ).toEqual([ "continueThreadsAfterServerUpdate", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -328,7 +333,12 @@ describe("findSharedSettingsMismatches", () => { environmentId: boxId, label: "Remote Box", syncEligible: true, - settings: { ...primarySettings, enableAgentBrowserAccess: false }, + settings: { + ...primarySettings, + enableAgentBrowserAccess: false, + defaultThreadEnvMode: + primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local", + }, }, ], }); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 6ce4323cd1..1302d96606 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 85546b9cce..6f70b70343 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,12 @@ 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 { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { + ForwardCompatibleNullable, + ProjectId, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { @@ -11,7 +16,7 @@ import { DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, } from "./model.ts"; -import { ModelSelection } from "./orchestration.ts"; +import { ModelSelection, ProjectScript } from "./orchestration.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, @@ -890,6 +895,22 @@ export const ServerSettings = Schema.Struct({ * between a desktop window and a phone attached to the same server. */ enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + defaultProjectScripts: Schema.Array(ProjectScript).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1170,6 +1191,18 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + projectAgentBrowserAccessOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + projectScriptOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), + ), + projectAutoPullOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3c..4d98e36b4d 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,24 @@ -import type { ProjectScript } from "@t3tools/contracts"; +import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; + +/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +export function resolveProjectScripts( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): readonly ProjectScript[] { + const override = settings.projectScriptOverrides[project.id]; + if (override === null) return settings.defaultProjectScripts; + return ( + override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) + ); +} + +export function projectScriptsInheritDefaults( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): boolean { + const override = settings.projectScriptOverrides[project.id]; + return override === null || (override === undefined && project.scripts.length === 0); +} interface ProjectScriptRuntimeEnvInput { project: { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 31f056c211..a5e428fcda 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, UsageLimitSourceId, @@ -9,14 +10,181 @@ import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; +import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, + resolveProjectAgentBrowserAccess, + resolveProjectAutoPull, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { + const project = { id: ProjectId.make("project-actions"), scripts: [] }; + const action = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(defaults, project)).toEqual([action]); + expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + const disabled = applyServerSettingsPatch(defaults, { + projectScriptOverrides: { [project.id]: [] }, + }); + expect(resolveProjectScripts(disabled, project)).toEqual([]); + expect(projectScriptsInheritDefaults(disabled, project)).toBe(false); + const changedDefault = applyServerSettingsPatch(disabled, { + defaultProjectScripts: [{ ...action, command: "npm run build" }], + }); + expect(resolveProjectScripts(changedDefault, project)).toEqual([]); + const reset = applyServerSettingsPatch(changedDefault, { + projectScriptOverrides: { [project.id]: null }, + }); + expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts); + expect(projectScriptsInheritDefaults(reset, existing)).toBe(true); + expect( + resolveProjectScripts( + applyServerSettingsPatch(reset, { defaultProjectScripts: [] }), + existing, + ), + ).toEqual([]); + }); + + it("preserves other projects' actions when overriding, clearing, or resetting one project", () => { + const firstProject = { id: ProjectId.make("first-project"), scripts: [] }; + const secondProject = { id: ProjectId.make("second-project"), scripts: [] }; + const defaultAction = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const firstAction = { ...defaultAction, command: "npm run lint" }; + const secondAction = { ...defaultAction, command: "npm run build" }; + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [defaultAction], + projectScriptOverrides: { [firstProject.id]: [firstAction] }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectScriptOverrides: { [secondProject.id]: [secondAction] }, + }); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]); + + const cleared = applyServerSettingsPatch(secondUpdate, { + projectScriptOverrides: { [firstProject.id]: [] }, + }); + expect(resolveProjectScripts(cleared, firstProject)).toEqual([]); + expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]); + + const reset = applyServerSettingsPatch(cleared, { + projectScriptOverrides: { [firstProject.id]: null }, + }); + expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([ + defaultAction, + ]); + expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + }); + + it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => { + const projectId = ProjectId.make("project-pull"); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true); + const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true }); + expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true); + const overridden = applyServerSettingsPatch(enabled, { + projectAutoPullOverrides: { [projectId]: false }, + }); + expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); + const reset = applyServerSettingsPatch(overridden, { + projectAutoPullOverrides: { [projectId]: null }, + }); + expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); + const disabled = applyServerSettingsPatch(reset, { + defaultAutoPull: false, + projectAutoPullOverrides: { [projectId]: true }, + }); + expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true); + expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false); + }); + + it("inherits browser access and restores inheritance when a project override is removed", () => { + const projectId = ProjectId.make("project-browser"); + const otherProjectId = ProjectId.make("other-project"); + const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAgentBrowserAccessOverrides: { [projectId]: false }, + }); + expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); + expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); + const reset = applyServerSettingsPatch(overridden, { + projectAgentBrowserAccessOverrides: { [projectId]: null }, + }); + expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); + const enabled = applyServerSettingsPatch(reset, { + enableAgentBrowserAccess: false, + projectAgentBrowserAccessOverrides: { [projectId]: true }, + }); + expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true); + expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false); + }); + + it("preserves other projects' boolean overrides across separate updates and resets", () => { + const firstProjectId = ProjectId.make("first-project"); + const secondProjectId = ProjectId.make("second-project"); + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + projectAutoPullOverrides: { [firstProjectId]: false }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: false }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectAutoPullOverrides: { [secondProjectId]: false }, + projectAgentBrowserAccessOverrides: { [secondProjectId]: false }, + }); + for (const projectId of [firstProjectId, secondProjectId]) { + expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false); + } + + const reset = applyServerSettingsPatch(secondUpdate, { + projectAutoPullOverrides: { [firstProjectId]: null }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: null }, + }); + expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true); + expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true); + expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false); + expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined(); + expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined(); + expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false); + }); + + it("replaces and clears conversation model defaults without retaining old options", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet"); + const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection }); + expect(updated.defaultModelSelection).toEqual(selection); + expect( + applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection, + ).toBeNull(); + }); + it("ignores missing and blank persisted observability URLs", () => { expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ otlpTracesUrl: undefined, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dc50da2d76..f969e4412c 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -3,6 +3,7 @@ import { isProviderAvailable, resolveProviderInstanceEnabled, type ModelSelection, + type ProjectId, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -23,6 +24,27 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +export function resolveProjectAgentBrowserAccess( + settings: Pick, + projectId: ProjectId, +): boolean { + return ( + settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + ); +} + +export function resolveProjectAutoPull( + settings: Pick, + projectId: ProjectId, + legacyAutoPull: boolean | undefined, +): boolean { + // Existing opt-ins stay enabled until explicitly overridden or reset. + return ( + settings.projectAutoPullOverrides[projectId] ?? + (legacyAutoPull === true || settings.defaultAutoPull) + ); +} + type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( @@ -151,6 +173,8 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, + projectAutoPullOverrides: projectAutoPullOverridesPatch, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -207,6 +231,36 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(projectAgentBrowserAccessOverridesPatch !== undefined + ? { + projectAgentBrowserAccessOverrides: mergeSettingsEntries( + current.projectAgentBrowserAccessOverrides, + projectAgentBrowserAccessOverridesPatch, + ), + } + : {}), + ...(projectAutoPullOverridesPatch !== undefined + ? { + projectAutoPullOverrides: mergeSettingsEntries( + current.projectAutoPullOverrides, + projectAutoPullOverridesPatch, + ), + } + : {}), + ...(patch.defaultModelSelection !== undefined + ? { defaultModelSelection: patch.defaultModelSelection } + : {}), + ...(patch.defaultProjectScripts !== undefined + ? { defaultProjectScripts: patch.defaultProjectScripts } + : {}), + ...(patch.projectScriptOverrides !== undefined + ? { + projectScriptOverrides: { + ...current.projectScriptOverrides, + ...patch.projectScriptOverrides, + }, + } + : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( From 091a1daaa01bb809d849825c8e882d14c4f0e292 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:33:13 -0300 Subject: [PATCH 02/15] feat(connections): balance new threads across connected machines Web and desktop can opt into Auto balance under Settings -> Connections. New drafts in projects grouped across machines pick a connected machine by whole-host CPU and memory, weighted by per-machine preference; a manual machine, branch or worktree choice pins the draft. Mobile keeps manual routing. Pylon adaptations: candidate machines use Pylon's shared provider admission rule (getProviderAdmissionUnavailableReason), so balancing never routes to a machine whose provider is disabled, not installed, signed out, unavailable or not ready (covers Prime and Antigravity); the capacity strip and provider banner fall back to the machine default model like the composer does; the section heading drops its description to match Pylon's settings layout (upstream #10415 hunk for this file). Adopted from 420fd76f60433fe05b8d2c76f4fbde430dc49968 (#9895) --- .../settings/DesktopClientSettings.test.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/resourceTelemetry/HostResources.ts | 93 ++++++++++++ apps/server/src/server.test.ts | 94 ++++++++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 6 + apps/web/src/components/BranchToolbar.tsx | 52 ++++++- .../BranchToolbarBranchSelector.tsx | 6 +- .../BranchToolbarEnvironmentSelector.tsx | 46 ++++-- apps/web/src/components/ChatView.tsx | 133 +++++++++++++++++- apps/web/src/components/GitActionsControl.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 2 + .../settings/LoadBalancingSettings.tsx | 91 ++++++++++++ .../src/components/settings/settingsSearch.ts | 8 ++ apps/web/src/composerDraftStore.test.ts | 57 ++++++++ apps/web/src/composerDraftStore.ts | 59 ++++++++ .../src/hooks/useLoadBalancedEnvironment.ts | 50 +++++++ docs/user/remote-access.md | 18 +++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/load-balancing.ts | 40 ++++++ .../src/state/projectGrouping.test.ts | 65 +++++++++ packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/resourceTelemetry.ts | 10 ++ packages/contracts/src/rpc.ts | 9 ++ packages/contracts/src/settings.test.ts | 16 +++ packages/contracts/src/settings.ts | 9 ++ 26 files changed, 864 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/resourceTelemetry/HostResources.ts create mode 100644 apps/web/src/components/settings/LoadBalancingSettings.tsx create mode 100644 apps/web/src/hooks/useLoadBalancedEnvironment.ts create mode 100644 packages/client-runtime/src/load-balancing.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 6673c49507..8a92d1e099 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -57,6 +57,8 @@ const clientSettings: ClientSettings = { sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, planModeEnabled: false, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 0fbf3d2d5a..4b02a78040 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -77,6 +77,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 0000000000..032832dd48 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b2d02ea5a5..df7d39bff4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -166,6 +166,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -806,7 +807,8 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide( + Layer.provide([ + HostResources.layer, Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ readHistory: (input) => Effect.succeed({ @@ -821,7 +823,7 @@ const buildAppUnderTest = (options?: { error: Option.none(), }), }), - ), + ]), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => @@ -6038,6 +6040,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns cached whole-host resources over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const [first, second] = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all( + [ + client[WS_METHODS.serverGetHostResources]({}), + client[WS_METHODS.serverGetHostResources]({}), + ], + { concurrency: "unbounded" }, + ), + ), + ); + assert.deepEqual(first, second); + assert.isAtLeast(first.sampledAt, 0); + assert.isAbove(first.cpuCount, 0); + assert.isAbove(first.totalMemoryBytes, 0); + assert.isAtLeast(first.availableMemoryBytes, 0); + assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes); + if (first.cpuUtilization !== null) { + assert.isAtLeast(first.cpuUtilization, 0); + assert.isAtMost(first.cpuUtilization, 1); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("counts macOS reclaimable memory once and shares concurrent samples", () => + Effect.gen(function* () { + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Ref.update(commandCalls, (count) => count + 1).pipe( + Effect.as( + "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + + "Pages purgeable: 999.\n", + ), + ), + }), + ), + ); + const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], { + concurrency: "unbounded", + }); + assert.equal(first.availableMemoryBytes, 35 * 16384); + assert.deepEqual(first, second); + assert.deepEqual(yield* hostResources.read, first); + assert.equal(yield* Ref.get(commandCalls), 1); + }).pipe(TestClock.withLive), + ); + + it.effect("retries host sampling immediately after its caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1); + if (call === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } + return ( + "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + ); + }), + }), + ), + ); + const firstRead = yield* hostResources.read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(firstRead); + const recovered = yield* hostResources.read; + assert.equal(recovered.availableMemoryBytes, 35 * 4096); + assert.equal(yield* Ref.get(commandCalls), 2); + }).pipe(TestClock.withLive), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d12af12ca8..d59f7058e0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -114,6 +114,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -204,6 +205,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a0e6df74a3..2953d6f527 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -157,6 +157,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -610,6 +611,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; @@ -2323,6 +2325,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 9d6d5585ff..a6e0a5102a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -7,6 +7,7 @@ import { FolderGitIcon, FolderIcon, HistoryIcon, + ScaleIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -59,6 +60,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -74,6 +77,8 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; @@ -89,6 +94,8 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, envModeLocked, environmentId, @@ -122,10 +129,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + {autoEnvironmentLabel ? ( + ) : ( @@ -142,7 +153,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-label-motion className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" > - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {autoEnvironmentLabel ?? + (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -175,9 +187,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Run on onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange(value as EnvironmentId) + } > + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} {availableEnvironments.map((env) => ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -182,6 +182,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -197,6 +198,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -503,7 +505,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 1f3369c091..df058307a4 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,6 +1,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { composerFloatingLayerProps } from "./chat/composerEventScope"; import type { EnvironmentId } from "@t3tools/contracts"; +import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; @@ -15,6 +16,8 @@ import { } from "./ui/select"; interface BranchToolbarEnvironmentSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -75,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir return ( + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4b5a0a9048..4f45a0beac 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -512,6 +512,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, + { + id: "load-balancing", + title: "Load balancing", + to: "/settings/connections", + searchTerms: [ + "automatic machine environment resources cpu memory capacity preference weight shared projects", + ], + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index b1c317eea6..5d9e079c77 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1624,6 +1624,63 @@ describe("composerDraftStore project draft thread mapping", () => { expect(file && composerFileNeedsReattach(file)).toBe(true); }); + it("rechecks balancing when an empty draft is remapped to another project member", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + environmentSelection: "auto", + loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID, + }); + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID }); + store.setDraftThreadContext(draftId, { projectRef }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + }); + + it("does not opt a legacy branch choice into balancing when runtime mode changes", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" }); + store.setDraftThreadContext(draftId, { runtimeMode: "full-access" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined(); + expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned"); + }); + + it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + store.setDraftThreadContext(draftId, { + projectRef: remoteProjectRef, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + store.setDraftThreadContext(draftId, { branch: "feature/pinned" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual"); + store.setDraftThreadContext(draftId, { + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e48b49912c..29a94f0ef5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -327,6 +327,8 @@ const PersistedDraftThreadState = Schema.Struct({ environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), + environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), + loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -451,6 +453,8 @@ export interface DraftSessionState { environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; @@ -527,6 +531,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -542,6 +548,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -556,6 +564,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1596,6 +1606,8 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ): DraftThreadState { // A project change (including switching environments within a logical @@ -1622,11 +1634,23 @@ function createDraftThreadState( options?.startFromOrigin === undefined ? (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const environmentSelection = + options?.environmentSelection ?? existingThread?.environmentSelection; return { threadId, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + ...(options?.loadBalancedEnvironmentId !== undefined + ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId } + : existingThread?.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: projectChanged + ? null + : existingThread.loadBalancedEnvironmentId, + } + : {}), createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: @@ -1661,6 +1685,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.environmentId === right.environmentId && left.projectId === right.projectId && left.logicalProjectKey === right.logicalProjectKey && + left.environmentSelection === right.environmentSelection && + left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId && left.createdAt === right.createdAt && left.runtimeMode === right.runtimeMode && left.interactionMode === right.interactionMode && @@ -1816,6 +1842,16 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + ...(candidateDraftThread.environmentSelection === "manual" || + candidateDraftThread.environmentSelection === "auto" + ? { environmentSelection: candidateDraftThread.environmentSelection } + : {}), + ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" && + candidateDraftThread.loadBalancedEnvironmentId.length > 0 + ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId } + : candidateDraftThread.loadBalancedEnvironmentId === null + ? { loadBalancedEnvironmentId: null } + : {}), promotedTo, }; } @@ -2561,6 +2597,15 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + ...(persistedDraftThread.environmentSelection + ? { environmentSelection: persistedDraftThread.environmentSelection } + : {}), + ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: + persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null, + } + : {}), promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2815,11 +2860,23 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const environmentSelection = + options.environmentSelection ?? + (options.branch != null || options.worktreePath != null + ? "manual" + : existing.environmentSelection); const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + loadBalancedEnvironmentId: + options.loadBalancedEnvironmentId === undefined + ? projectChanged + ? null + : (existing.loadBalancedEnvironmentId ?? null) + : options.loadBalancedEnvironmentId, createdAt: options.createdAt === undefined ? existing.createdAt @@ -2837,6 +2894,8 @@ const composerDraftStore = create()( nextDraftThread.environmentId === existing.environmentId && nextDraftThread.projectId === existing.projectId && nextDraftThread.logicalProjectKey === existing.logicalProjectKey && + nextDraftThread.environmentSelection === existing.environmentSelection && + nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId && nextDraftThread.createdAt === existing.createdAt && nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.interactionMode === existing.interactionMode && diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts new file mode 100644 index 0000000000..5d3ddfb8b1 --- /dev/null +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -0,0 +1,50 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext, useMemo } from "react"; + +import { serverEnvironment } from "../state/server"; + +/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ +export function useLoadBalancedEnvironment( + environmentIds: readonly EnvironmentId[], + weights: Readonly>, +) { + const registry = useContext(RegistryContext); + const refresh = useCallback( + (ids: readonly EnvironmentId[]) => { + for (const environmentId of ids) { + registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} })); + } + }, + [registry], + ); + const resourcesAtom = useMemo( + () => + Atom.make((get) => + environmentIds.map((environmentId) => { + const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); + return { + environmentId, + resources: result._tag === "Success" ? result.value : null, + receivedAt: result._tag === "Success" ? result.timestamp : 0, + pending: result._tag === "Initial" || result.waiting, + }; + }), + ), + [environmentIds], + ); + const resources = useAtomValue(resourcesAtom); + return { + refresh, + pending: resources.some((resource) => resource.pending), + environmentId: chooseLoadBalancedEnvironment( + resources.map((resource) => ({ + ...resource, + weight: weights[resource.environmentId] ?? 50, + })), + Date.now(), + ) as EnvironmentId | null, + }; +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 9d3e7be03b..0f1d4737a4 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -67,6 +67,24 @@ shell actions when the environment is remote or its location is still being reso a remote browser from opening a file manager on an unattended server. Integrated preview, browser, and copy-path actions remain available when supported. +## Balance New Threads Across Machines + +When a project is grouped across several connected environments, Pylon can choose a machine for +each new thread. Auto balance is off by default. On web and desktop, turn it on in +**Settings** → **Connections** → **Load balancing**. + +Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and memory +available, **Less often** to reduce its share, or **Manual only** to exclude it from automatic +selection. These are preferences, not fixed traffic percentages, and each client saves its own. + +A machine is only chosen when it is connected, has the project, and can run the selected provider: +the provider must be installed, enabled, signed in, and available there. The composer checks those +machines when it picks a draft's environment, then keeps that choice. Choose **Auto balance** again +to check current resources, or choose a specific machine to override it. Choosing a branch or +worktree also keeps the draft on that machine. Existing threads stay where they started. If +resource checks fail or every eligible machine is busy, choose a machine yourself to continue. +The mobile app keeps its manual environment selection. + ## Enabling Network Access There are three ways to reach your server from another device: expose the desktop app's backend, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 09f09d8a7b..c01cdd33dd 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./load-balancing": { + "types": "./src/load-balancing.ts", + "default": "./src/load-balancing.ts" + }, "./pending-requests": { "types": "./src/pendingRequests.ts", "default": "./src/pendingRequests.ts" diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts new file mode 100644 index 0000000000..0b938c6c09 --- /dev/null +++ b/packages/client-runtime/src/load-balancing.ts @@ -0,0 +1,40 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; + +/** Callers supply only connected machines hosting the project and selected provider. */ +export function chooseLoadBalancedEnvironment( + candidates: ReadonlyArray<{ + environmentId: string; + resources: HostResourcesSnapshot | null; + /** Client receipt time avoids comparing clocks on different machines. */ + receivedAt?: number; + weight: number; + }>, + now: number, +): string | null { + let selected: string | null = null; + let bestScore = 0; + for (const { environmentId, resources, receivedAt, weight } of candidates) { + const sampledAt = receivedAt ?? resources?.sampledAt ?? 0; + if ( + !resources || + !Number.isFinite(weight) || + weight <= 0 || + now - sampledAt > 15_000 || + sampledAt > now + 5_000 || + resources.cpuUtilization === null || + resources.cpuUtilization >= 0.95 || + resources.totalMemoryBytes <= 0 || + resources.cpuCount <= 0 + ) { + continue; + } + const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes; + if (memoryAvailable <= 0.05) continue; + const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable; + if (score > bestScore) { + selected = environmentId; + bestScore = score; + } + } + return selected; +} diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 94d213b257..4884c3b99b 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; +import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -9,6 +10,70 @@ import { } from "./projectGrouping.ts"; const environmentId = EnvironmentId.make("environment"); + +describe("load balancing shared project machines", () => { + const now = 100_000; + const resources = { + sampledAt: now, + cpuUtilization: 0.2, + cpuCount: 8, + availableMemoryBytes: 8_000, + totalMemoryBytes: 16_000, + }; + + it("compares three machines using free capacity and preference", () => { + const candidates = [ + { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 }, + { environmentId: "idle", resources, weight: 1 }, + { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 }, + ]; + expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred"); + expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle"); + }); + + it("rejects stale, unknown, excluded and saturated machines", () => { + expect( + chooseLoadBalancedEnvironment( + [ + { + environmentId: "stale", + resources: { ...resources, sampledAt: now - 15_001 }, + weight: 1, + }, + { environmentId: "unknown", resources: null, weight: 1 }, + { + environmentId: "no-cpu-sample", + resources: { ...resources, cpuUtilization: null }, + weight: 1, + }, + { environmentId: "excluded", resources, weight: 0 }, + { + environmentId: "cpu-full", + resources: { ...resources, cpuUtilization: 0.95 }, + weight: 1, + }, + { + environmentId: "memory-full", + resources: { ...resources, availableMemoryBytes: 100 }, + weight: 1, + }, + ], + now, + ), + ).toBeNull(); + }); + + it("uses client receipt time when host clocks differ", () => { + const candidate = { + environmentId: "different-clock", + resources: { ...resources, sampledAt: now + 60_000 }, + receivedAt: now, + weight: 1, + }; + expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock"); + expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull(); + }); +}); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", locator: { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index b2d8d3f197..aa1170c551 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createRuntimeCommand, @@ -995,6 +996,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostResources: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-resources", + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), + }), processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index fc519c3bc6..ee9d2b3ac2 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 3 as const; +/** Whole-host capacity, independent of T3's process diagnostics. */ +export const HostResourcesSnapshot = Schema.Struct({ + sampledAt: NonNegativeInt, + cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + cpuCount: NonNegativeInt, + availableMemoryBytes: NonNegativeInt, + totalMemoryBytes: NonNegativeInt, +}); +export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; + export const ResourceTelemetryIoSemantics = Schema.Literals([ "storage", "logical", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 52e050ea5d..4dd1f6c8ee 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -261,6 +261,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { + HostResourcesSnapshot, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -405,6 +406,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetHostResources: "server.getHostResources", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -815,6 +817,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia error: EnvironmentAuthorizationError, }); +const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { + payload: Schema.Struct({}), + success: HostResourcesSnapshot, + error: EnvironmentAuthorizationError, +}); + const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { payload: ServerProcessResourceHistoryInput, success: ServerProcessResourceHistoryResult, @@ -1479,6 +1487,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetHostResourcesRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 328c8188ce..be7f5d1f35 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -14,6 +14,7 @@ import { const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); +const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -162,6 +163,21 @@ describe("ClientSettings retired status motion", () => { }); }); +describe("ClientSettings load balancing", () => { + it("requires opt-in when settings are new or omit load balancing", () => { + expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); + expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); + }); + + it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { + const settings = decodeClientSettings({ loadBalancingEnabled }); + expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); + expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( + loadBalancingEnabled, + ); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6f70b70343..0f0a3e9b93 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -189,7 +189,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; +export const LoadBalancingWeights = Schema.Record( + TrimmedNonEmptyString, + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), +); + export const ClientSettingsSchema = Schema.Struct({ + loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), @@ -1312,6 +1319,8 @@ export class ServerSettingsUpdateConflictError extends Schema.TaggedErrorClass Date: Sun, 6 Sep 2026 19:54:32 -0300 Subject: [PATCH 03/15] fix(web): only show auto balance errors after failed checks (#10407) (cherry picked from commit 2d6a3799978659e349faf8ff4297098ffb782ebb) --- apps/web/src/components/ChatView.tsx | 4 +++- .../src/hooks/useLoadBalancedEnvironment.ts | 20 +++++++++++-------- packages/client-runtime/src/state/server.ts | 1 + 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6ddf9b8a9b..60c698c770 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3631,7 +3631,9 @@ export default function ChatView(props: ChatViewProps) { ? "Auto balance" : loadBalancing.pending ? "Checking machines…" - : "Auto balance unavailable" + : loadBalancing.failed + ? "Auto balance unavailable" + : "Auto balance" : undefined; // Handle environment change for draft threads. When the user picks a diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts index 5d3ddfb8b1..701ee04706 100644 --- a/apps/web/src/hooks/useLoadBalancedEnvironment.ts +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -30,21 +30,25 @@ export function useLoadBalancedEnvironment( resources: result._tag === "Success" ? result.value : null, receivedAt: result._tag === "Success" ? result.timestamp : 0, pending: result._tag === "Initial" || result.waiting, + failed: result._tag === "Failure", }; }), ), [environmentIds], ); const resources = useAtomValue(resourcesAtom); + const pending = resources.some((resource) => resource.pending); + const environmentId = chooseLoadBalancedEnvironment( + resources.map((resource) => ({ + ...resource, + weight: weights[resource.environmentId] ?? 50, + })), + Date.now(), + ) as EnvironmentId | null; return { refresh, - pending: resources.some((resource) => resource.pending), - environmentId: chooseLoadBalancedEnvironment( - resources.map((resource) => ({ - ...resource, - weight: weights[resource.environmentId] ?? 50, - })), - Date.now(), - ) as EnvironmentId | null, + pending, + environmentId, + failed: !pending && environmentId === null && resources.some((resource) => resource.failed), }; } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index aa1170c551..5ce1e0b32d 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -998,6 +998,7 @@ export function createServerEnvironmentAtoms( }), hostResources: createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:server:host-resources", + idleTtlMs: 0, staleTimeMs: 5_000, execute: (input: EnvironmentRpcInput) => request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), From cfaad10ed695891bbbf1bffaa1798239c8e0aa30 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 21:43:04 -0300 Subject: [PATCH 04/15] fix(web): show load balancing note for a single machine (#10433) (cherry picked from commit efeac144289d0f64bcfe3b9274c616499893be91) --- .../src/components/settings/LoadBalancingSettings.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/components/settings/LoadBalancingSettings.tsx b/apps/web/src/components/settings/LoadBalancingSettings.tsx index b4657b7138..c56c6f8d73 100644 --- a/apps/web/src/components/settings/LoadBalancingSettings.tsx +++ b/apps/web/src/components/settings/LoadBalancingSettings.tsx @@ -27,6 +27,16 @@ export function LoadBalancingSettings({ const settingsHydrated = useClientSettingsHydrated(); const updateSettings = useUpdateClientSettings(); + if (environments.length < 2) { + return ( + +

+ Connect another machine to automatically balance load across environments. +

+
+ ); + } + return ( Date: Mon, 7 Sep 2026 19:13:04 -0300 Subject: [PATCH 05/15] fix(web): make settings project scopes searchable and scrollable (#10570) (cherry picked from commit c8ec7df12415140c6413372f53e3be4fe2e959f8) --- apps/web/src/components/Sidebar.tsx | 72 ++++++++---------- .../components/settings/ProjectsSettings.tsx | 73 ++++++++++++------- apps/web/src/components/ui/combobox.tsx | 24 +++++- 3 files changed, 97 insertions(+), 72 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 49a7e5be37..8e61980652 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -222,7 +222,7 @@ import { Input } from "./ui/input"; import { Combobox, ComboboxEmpty, - ComboboxInput, + ComboboxSearchInput, ComboboxItem, ComboboxList, ComboboxPopup, @@ -4575,48 +4575,34 @@ export default function Sidebar() { align="start" className="w-(--anchor-width) min-w-0 overflow-hidden" > -
-
-
-
+ { + if ( + event.defaultPrevented || + event.nativeEvent.isComposing || + event.ctrlKey || + event.altKey || + event.metaKey || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + // Combobox items use virtual focus: keyboard events + // stay on this input, not on the highlighted option. + const scopeKey = highlightedProjectScopeKeyRef.current; + const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + if (project) handleProjectSettings(event, project); + }} + onChange={(event) => + dispatchProjectScopeMenu({ + type: "query-changed", + query: event.target.value, + }) + } + /> No matching projects. {(item: (typeof projectScopeItems)[number]) => { diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index 4eedaab638..b454517bcd 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -1,12 +1,22 @@ import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { FolderIcon } from "lucide-react"; -import type { ReactNode } from "react"; +import { ChevronDownIcon, FolderIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { ProjectFavicon } from "../ProjectFavicon"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { useEnvironments } from "../../state/environments"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { + Combobox, + ComboboxEmpty, + ComboboxSearchInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; +import { selectTriggerVariants } from "../ui/select"; +import { cn } from "../../lib/utils"; import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; @@ -21,44 +31,51 @@ function ScopePicker({ options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; onChange: (value: string | null) => void; }) { + const [query, setQuery] = useState(""); const selected = options.find((option) => option.value === value); const allIcon = label === "project" ? : null; + const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options]; return ( - + + + + + + No matching {label}s. + + {(item: (typeof items)[number]) => ( + + {item.icon} + {item.label} + + )} + + + ); } diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index f18bd9f7bd..6437f1fc87 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -1,7 +1,7 @@ "use client"; import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; -import { ChevronsUpDownIcon, XIcon } from "lucide-react"; +import { ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react"; import * as React from "react"; import { cn } from "~/lib/utils"; @@ -130,6 +130,27 @@ function ComboboxInput({ ); } +function ComboboxSearchInput(props: React.ComponentProps) { + return ( +
+
+
+
+ ); +} + function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) { return ( @@ -393,6 +414,7 @@ export { Combobox, ComboboxChipsInput, ComboboxInput, + ComboboxSearchInput, ComboboxTrigger, ComboboxPopup, ComboboxItem, From 6c118662f3a697ec8e64a3d4910cd147d6f16929 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 8 Sep 2026 00:17:42 -0300 Subject: [PATCH 06/15] fix(web): update machines together in auto balance Auto balance shows how many machines need an update and updates the supported ones together, with per-machine progress, retry and manual instructions from the existing update flow. Pylon adaptation: the batch and single desktop-update confirmations say Pylon instead of T3 Code. Adopted from 15193df9fb915837f7215909a7d741789db0a273 (#10596) --- apps/web/src/components/ChatView.tsx | 52 ++++-- .../components/ServerUpdateAction.test.tsx | 161 +++++++++++++++- .../web/src/components/ServerUpdateAction.tsx | 174 ++++++++++++------ .../chat/useAutoBalanceUpdateBanner.tsx | 160 ++++++++++++++++ 4 files changed, 472 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 60c698c770..12ad958e7e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -502,6 +502,7 @@ import { } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; +import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; import { ComposerServerUpdateIcon, ComposerServerUpdateStatus, @@ -2468,6 +2469,35 @@ export default function ChatView(props: ChatViewProps) { advertisedFileAttachmentBytes === null ? null : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); + const envLocked = Boolean( + activeThread && + (activeThread.messages.length > 0 || + (activeThread.session !== null && activeThread.session.status !== "stopped")), + ); + + const loadBalancingSettings = useClientSettings(); + const automaticEnvironment = Boolean( + clientSettingsHydrated && + draftId && + !envLocked && + hasMultipleEnvironments && + loadBalancingSettings.loadBalancingEnabled && + draftThread?.environmentSelection !== "manual" && + (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && + (!draftThread?.branch || draftThread.environmentSelection === "auto") && + !draftThread?.worktreePath, + ); + const autoUpdateEnvironments = useMemo( + () => + automaticEnvironment + ? logicalProjectEnvironments.flatMap(({ environmentId }) => { + const environment = environmentById.get(environmentId); + return environment ? [environment] : []; + }) + : [], + [automaticEnvironment, logicalProjectEnvironments, environmentById], + ); + const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2573,6 +2603,7 @@ export default function ChatView(props: ChatViewProps) { } } if ( + !automaticEnvironment && serverUpdateEnvironmentId && !reconnectingThroughVersionSkew && (serverUpdateState.status === "idle" @@ -2655,8 +2686,11 @@ export default function ChatView(props: ChatViewProps) { }), }); } + if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); return items; }, [ + automaticEnvironment, + autoBalanceUpdateBanner, activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, @@ -3524,24 +3558,6 @@ export default function ChatView(props: ChatViewProps) { } }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); - const envLocked = Boolean( - activeThread && - (activeThread.messages.length > 0 || - (activeThread.session !== null && activeThread.session.status !== "stopped")), - ); - - const loadBalancingSettings = useClientSettings(); - const automaticEnvironment = Boolean( - clientSettingsHydrated && - draftId && - !envLocked && - hasMultipleEnvironments && - loadBalancingSettings.loadBalancingEnabled && - draftThread?.environmentSelection !== "manual" && - (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && - (!draftThread?.branch || draftThread.environmentSelection === "auto") && - !draftThread?.worktreePath, - ); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = useMemo( () => diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index f99dc411fb..3b6fc4d180 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -1,9 +1,10 @@ -import type { ReactElement } from "react"; +import { act, type ReactElement } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { renderToStaticMarkup } from "react-dom/server"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ updateServer: vi.fn(), @@ -30,7 +31,18 @@ vi.mock("./ui/toast", () => ({ toastManager: { add: testState.toast }, })); -import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction"; +import { + readConfirmDialogState, + registerConfirmDialogHost, + resetConfirmDialogForTests, + respondToConfirmDialog, +} from "~/confirmDialog"; +import { + ServerUpdateAction, + ServerUpdateProgress, + ServerUpdatesAction, + type ServerUpdateTarget, +} from "./ServerUpdateAction"; type ActionElement = ReactElement<{ readonly onClick?: () => void; @@ -194,6 +206,149 @@ describe("ServerUpdateAction", () => { }); }); +describe("ServerUpdatesAction", () => { + let renderer: ReactTestRenderer | undefined; + const targets: ReadonlyArray = [ + { + environmentId: "batch-a" as EnvironmentId, + serverLabel: "Laptop", + selfUpdate: "boot-service", + targetVersion: "0.0.31", + threadContinuation: true, + continueThreadsAfterServerUpdate: true, + }, + { + environmentId: "batch-b" as EnvironmentId, + serverLabel: "Office", + selfUpdate: "respawn", + targetVersion: "0.0.31", + threadContinuation: true, + continueThreadsAfterServerUpdate: false, + }, + { + environmentId: "batch-c" as EnvironmentId, + serverLabel: "Manual", + selfUpdate: null, + targetVersion: "0.0.31", + }, + ]; + const success = AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const }); + + async function mount(batch = targets) { + await act(async () => { + renderer = create(); + }); + return renderer!.root.findByType("button"); + } + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + testState.updateServer.mockReset(); + testState.toast.mockReset(); + resetConfirmDialogForTests(); + }); + afterEach(async () => { + await act(async () => { + renderer?.unmount(); + }); + renderer = undefined; + resetConfirmDialogForTests(); + vi.unstubAllGlobals(); + }); + + it("updates both supported machines with their own continuation preference and skips the manual machine", async () => { + testState.updateServer.mockResolvedValue(success); + const button = await mount(); + await act(async () => { + button.props.onClick(); + }); + + expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([ + { + environmentId: "batch-a", + input: { targetVersion: "0.0.31", continueRunningThreads: true }, + }, + { environmentId: "batch-b", input: { targetVersion: "0.0.31" } }, + ]); + expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([ + "Laptop updated", + "Office updated", + ]); + }); + + it("names a failed machine while letting the other machine complete", async () => { + testState.updateServer + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed")))) + .mockResolvedValueOnce(success); + const button = await mount(); + await act(async () => { + button.props.onClick(); + }); + + expect(testState.updateServer).toHaveBeenCalledTimes(2); + expect(testState.toast).toHaveBeenCalledWith({ + type: "error", + title: "Laptop update failed", + description: "Download failed", + }); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Office updated" }), + ); + expect(button.props.disabled).toBe(false); + }); + + it("starts each machine once when double-clicked and disables the action until both finish", async () => { + const completions: Array<() => void> = []; + testState.updateServer.mockImplementation( + () => + new Promise((resolve) => { + completions.push(() => resolve(success)); + }), + ); + const button = await mount(); + await act(async () => { + button.props.onClick(); + button.props.onClick(); + }); + expect(testState.updateServer).toHaveBeenCalledTimes(2); + expect(button.props.disabled).toBe(true); + await act(async () => { + completions[0]!(); + }); + expect(button.props.disabled).toBe(true); + await act(async () => { + completions[1]!(); + }); + expect(button.props.disabled).toBe(false); + expect(testState.toast).toHaveBeenCalledTimes(2); + }); + + it("asks once for desktop machines and cancels the entire batch", async () => { + registerConfirmDialogHost(); + const button = await mount( + targets.map((target, index) => + index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target, + ), + ); + await act(async () => { + button.props.onClick(); + }); + const confirmation = readConfirmDialogState(); + expect(confirmation).toEqual( + expect.objectContaining({ + status: "confirming", + message: expect.stringContaining("Laptop, Office"), + }), + ); + expect(testState.updateServer).not.toHaveBeenCalled(); + await act(async () => { + respondToConfirmDialog(false); + }); + expect(testState.updateServer).not.toHaveBeenCalled(); + expect(button.props.disabled).toBe(false); + }); +}); + describe("ServerUpdateProgress", () => { it("shows one calm status row for the restart wait", () => { const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 2d2a0f4a26..7ca17dc866 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -4,7 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ComponentProps } from "react"; +import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -34,6 +34,113 @@ function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } +export interface ServerUpdateTarget { + readonly environmentId: EnvironmentId; + readonly serverLabel: string; + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly desktopAppUpdate?: boolean; + readonly threadContinuation?: boolean; + readonly targetVersion: string; + readonly continueThreadsAfterServerUpdate?: boolean; +} + +type UpdateButtonProps = Pick, "variant" | "size"> & { + readonly label?: string; +}; + +function useServerUpdate() { + const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); + return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => { + const { environmentId, serverLabel, selfUpdate, targetVersion } = target; + if (pendingUpdateEnvironmentIds.has(environmentId)) return; + pendingUpdateEnvironmentIds.add(environmentId); + try { + const result = await updateServer({ + environmentId, + input: { + targetVersion, + ...(target.threadContinuation && target.continueThreadsAfterServerUpdate + ? { continueRunningThreads: true } + : {}), + }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + throw squashAtomCommandFailure(result); + } + toastManager.add({ + type: "success", + title: `${serverLabel} updated`, + description: + selfUpdate === "desktop-managed" + ? `Desktop app relaunched on ${result.value.targetVersion}.` + : `Reconnected on t3@${result.value.targetVersion}.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: failureTitle, + description: updateFailureMessage(error), + }); + } finally { + pendingUpdateEnvironmentIds.delete(environmentId); + } + }; +} + +/** Updates eligible machines independently; manual paths remain in the machine list. */ +export function ServerUpdatesAction({ + targets, + label = "Update all", + variant = "outline", + size = "xs", +}: UpdateButtonProps & { + readonly targets: ReadonlyArray; +}) { + const update = useServerUpdate(); + const pending = useRef(false); + const [isPending, setIsPending] = useState(false); + const eligible = targets.filter( + (target) => + target.selfUpdate !== null && + (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), + ); + const handleUpdate = async () => { + if (pending.current) return; + pending.current = true; + setIsPending(true); + try { + const available = eligible.filter( + (target) => !pendingUpdateEnvironmentIds.has(target.environmentId), + ); + const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed"); + if (desktopTargets.length > 0) { + const confirmed = + (await requestConfirmDialog( + `Update the Pylon desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, + )) ?? true; + if (!confirmed) return; + } + await Promise.all( + available.map((target) => update(target, `${target.serverLabel} update failed`)), + ); + } finally { + pending.current = false; + setIsPending(false); + } + }; + return ( + + ); +} + /** * One-row status for an in-flight server update: "Downloading…" then * "Restarting…". The update is a wait, not a warning: a single pulsing dot @@ -84,28 +191,13 @@ export function ServerUpdateAction({ label = "Update", variant = "outline", size = "xs", -}: { - readonly environmentId: EnvironmentId; - readonly serverLabel: string; - readonly selfUpdate: ServerSelfUpdateCapability | null; - /** The desktop app supervising this server accepts remote update - requests (capabilities.desktopAppUpdate). */ - readonly desktopAppUpdate?: boolean; - /** The server can durably continue running provider turns after updating. */ - readonly threadContinuation?: boolean; - readonly targetVersion: string; - readonly label?: string; - readonly variant?: ComponentProps["variant"]; - readonly size?: ComponentProps["size"]; -}) { +}: Omit & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, ); - const updateServer = useAtomCommand(serverEnvironment.updateServer, { - reportFailure: false, - }); + const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { @@ -134,47 +226,21 @@ export function ServerUpdateAction({ // remote machine installs without asking anyone there. const confirmed = (await requestConfirmDialog( - `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, + `Update the Pylon desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, )) ?? true; if (!confirmed) { return; } } - if (pendingUpdateEnvironmentIds.has(environmentId)) { - return; - } - pendingUpdateEnvironmentIds.add(environmentId); - try { - const result = await updateServer({ - environmentId, - input: { - targetVersion, - ...(threadContinuation && continueThreadsAfterServerUpdate - ? { continueRunningThreads: true } - : {}), - }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - toastManager.add({ - type: "error", - title: "Server update failed", - description: updateFailureMessage(squashAtomCommandFailure(result)), - }); - return; - } - toastManager.add({ - type: "success", - title: `${serverLabel} updated`, - description: isDesktopAppUpdate - ? `Desktop app relaunched on ${result.value.targetVersion}.` - : `Reconnected on t3@${result.value.targetVersion}.`, - }); - } finally { - pendingUpdateEnvironmentIds.delete(environmentId); - } + await update({ + environmentId, + serverLabel, + selfUpdate, + desktopAppUpdate, + threadContinuation, + targetVersion, + continueThreadsAfterServerUpdate, + }); }; if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { diff --git a/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx new file mode 100644 index 0000000000..0d45845339 --- /dev/null +++ b/apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx @@ -0,0 +1,160 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; +import { Atom } from "effect/unstable/reactivity"; +import { useMemo, useState } from "react"; + +import type { EnvironmentPresentation } from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { + buildVersionMismatchDismissalKey, + dismissServerUpdateFailure, + dismissVersionMismatch, + isServerUpdateFailureDismissed, + isVersionMismatchDismissed, + resolveServerConfigVersionMismatch, + resolveServerSelfUpdateCapability, + supportsDesktopAppUpdate, + supportsServerUpdateThreadContinuation, +} from "~/versionSkew"; +import { + ServerUpdateAction, + ServerUpdateProgress, + ServerUpdatesAction, +} from "../ServerUpdateAction"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; +import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; + +/** Keep every machine's update visible while auto balance has no single update target. */ +export function useAutoBalanceUpdateBanner( + environments: readonly EnvironmentPresentation[], +): ComposerBannerStackItem | null { + const statesAtom = useMemo( + () => + Atom.make((get) => + environments.map((environment) => ({ + environment, + state: get(serverEnvironment.updateStateAtom(environment.environmentId)), + })), + ), + [environments], + ); + const states = useAtomValue(statesAtom); + const [dismissedNotices, setDismissedNotices] = useState>( + () => new Set(), + ); + const machines = states.flatMap(({ environment, state }) => { + const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const dismissKey = mismatch + ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch) + : null; + if ( + state.status === "idle" + ? !mismatch || + (dismissKey !== null && dismissedNotices.has(dismissKey)) || + isVersionMismatchDismissed(dismissKey) + : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state) + ) + return []; + const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig); + const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig); + return [ + { + environmentId: environment.environmentId, + serverLabel: environment.label, + selfUpdate, + desktopAppUpdate, + threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig), + continueThreadsAfterServerUpdate: + environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false, + targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion, + connected: environment.connection.phase === "connected", + remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate), + state, + dismissKey, + }, + ]; + }); + if (machines.length === 0) return null; + + const running = machines.filter((machine) => machine.state.status === "running").length; + const failed = machines.filter((machine) => machine.state.status === "failed").length; + const manual = machines.filter((machine) => !machine.remoteUpdate).length; + const targets = machines.filter( + (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running", + ); + const count = running || failed || machines.length; + const status = running ? "running" : failed ? "failed" : "idle"; + const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for"; + const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`; + return { + id: `auto-balance-server-updates-${dismissedNotices.size}`, + variant: failed ? "error" : "default", + priority: running ? "urgent" : "notice", + icon: , + title: ( + + + {title} + + +
+ {machines.map((machine) => ( +
+
{machine.serverLabel}
+ {machine.state.status !== "idle" ? ( + + ) : !machine.remoteUpdate ? ( + <> +
Manual update required
+ + + ) : ( +
+ {machine.connected + ? `Ready to update to ${machine.targetVersion}` + : "Reconnect this machine to update"} +
+ )} +
+ ))} +
+
+
+ ), + description: + manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined, + actions: + running === 0 && targets.length > 0 ? ( + 0 + ? "Retry" + : targets.length === machines.length + ? "Update all" + : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}` + } + /> + ) : undefined, + dismissLabel: "Dismiss update notice", + ...(running + ? {} + : { + onDismiss: () => { + const next = new Set(dismissedNotices); + for (const { state, dismissKey } of machines) { + dismissServerUpdateFailure(state); + dismissVersionMismatch(dismissKey); + if (dismissKey) next.add(dismissKey); + if (state.status === "failed") next.add(state); + } + setDismissedNotices(next); + }, + }), + }; +} From 9252771b72d9c25a9ed8861c63f8e2d0a481a65a Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:37:37 +0300 Subject: [PATCH 07/15] feat(web): add pull request merge defaults Pull requests remember the last allowed merge method, a project can set its own default in its settings, and the merge menu closes after a choice. Pylon adaptations: only the new client-settings key was spliced into Pylon's settings schema (Pylon keeps contextWindowIndicatorEnabled and its own plan-mode and composer keys); Pylon's server-projected PR tracking is untouched. Settings search now also finds project names, icons and the merge method under Settings -> Projects. Adopted from 7d9aaf6a7eb22d5e85b0e3b21686cc437fe68576 (#8088) --- .../settings/DesktopClientSettings.test.ts | 1 + .../pullRequest/PullRequestDetailPanel.tsx | 85 ++++++++++++++----- .../pullRequestDetail.logic.test.ts | 16 ++++ .../pullRequest/pullRequestDetail.logic.ts | 19 +++++ .../settings/ProjectSettingsPanel.tsx | 51 +++++++++++ .../src/components/settings/settingsSearch.ts | 1 + apps/web/src/uiStateStore.test.ts | 15 ++++ apps/web/src/uiStateStore.ts | 26 +++++- packages/contracts/src/settings.test.ts | 19 +++++ packages/contracts/src/settings.ts | 8 ++ 10 files changed, 220 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8a92d1e099..bdbae6350f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -59,6 +59,7 @@ const clientSettings: ClientSettings = { planModeEnabled: false, loadBalancingEnabled: false, loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, + pullRequestMergeMethodOverrides: {}, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 113bb44953..5ecfe01e5f 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -53,14 +53,21 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useClientSettings } from "~/hooks/useSettings"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, + selectProjectGroupingSettings, +} from "~/logicalProject"; import { changeRequestRepositoryUrl, gitHubPullRequestBrowserUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; +import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects } from "~/state/entities"; -import { useEnvironments } from "~/state/environments"; +import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { @@ -71,6 +78,7 @@ import { import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { useUiStateStore } from "~/uiStateStore"; import { AlertDialog, @@ -124,11 +132,13 @@ import { pullRequestCheckoutCommand, pullRequestFindingKey, pullRequestHandoffLabels, + PULL_REQUEST_MERGE_METHOD_LABELS, readableFailure, readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, resolveBaseFreshness, + resolvePullRequestMergeMethod, type PullRequestFinding, shouldRefreshPullRequestActivity, writePullRequestDetailSnapshot, @@ -168,12 +178,6 @@ const ACTION_SUCCESS_LABELS: Record = { "approve-workflows": "Workflows approved", }; -const MERGE_METHOD_LABELS: Record = { - merge: "Merge", - squash: "Squash", - rebase: "Rebase", -}; - /** Said as the thing that did not happen, rather than as the operation that returned an error. */ const ACTION_FAILURE_LABELS: Record = { merge: "Could not merge this pull request", @@ -557,12 +561,16 @@ export function PullRequestDetailPanel({ compensationRef.current = null; if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); + const lastSelectedMergeMethod = useUiStateStore((state) => state.pullRequestMergeMethod); + const setLastSelectedMergeMethod = useUiStateStore((state) => state.setPullRequestMergeMethod); + const mergeMethodOverrides = useClientSettings( + (settings) => settings.pullRequestMergeMethodOverrides, + ); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const [mergeMethodSelection, setMergeMethodSelection] = useState<{ readonly pullRequestKey: string; readonly method: PullRequestMergeMethod; - }>(() => ({ pullRequestKey, method: "merge" })); - const mergeMethod = - mergeMethodSelection.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : "merge"; + } | null>(null); const setMergeMethod = (method: PullRequestMergeMethod) => { setMergeMethodSelection({ pullRequestKey, method }); }; @@ -767,6 +775,7 @@ export function PullRequestDetailPanel({ const [titleSaving, setTitleSaving] = useState(false); const newThread = useNewThreadHandler(); const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const projects = useProjects(); const unavailableGitHubUrl = useMemo(() => { const identity = projects.find( @@ -774,6 +783,30 @@ export function PullRequestDetailPanel({ )?.repositoryIdentity; return gitHubPullRequestBrowserUrl(identity, reference.repository, reference.number); }, [environmentId, projects, reference.number, reference.projectId, reference.repository]); + // Project settings store the override under the sidebar group's key, which a duplicate row + // borrows from its siblings, so the project alone does not always name the same key. + const projectDefaultMergeMethod = useMemo(() => { + const project = projects.find( + (candidate) => + candidate.environmentId === environmentId && candidate.id === reference.projectId, + ); + if (!project) return undefined; + const projectKey = + buildPhysicalToLogicalProjectKeyMap({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }).get(derivePhysicalProjectKey(project)) ?? + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings); + return mergeMethodOverrides[projectKey]; + }, [ + environmentId, + mergeMethodOverrides, + primaryEnvironmentId, + projectGroupingSettings, + projects, + reference.projectId, + ]); // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and // the thread is already on one server's copy of the branch. const pickableEnvironments = useMemo( @@ -1238,10 +1271,15 @@ export function PullRequestDetailPanel({ const allowedMergeMethods = detail ? detail.capabilities.mergeMethods.filter((method) => detail.mergeCapabilities[method]) : []; - const selectedMergeMethod = allowedMergeMethods.includes(mergeMethod) - ? mergeMethod - : (allowedMergeMethods[0] ?? "merge"); - const selectedMergeMethodLabel = MERGE_METHOD_LABELS[selectedMergeMethod]; + const currentMergeMethod = + mergeMethodSelection?.pullRequestKey === pullRequestKey ? mergeMethodSelection.method : null; + const selectedMergeMethod = resolvePullRequestMergeMethod( + allowedMergeMethods, + currentMergeMethod, + projectDefaultMergeMethod, + lastSelectedMergeMethod, + ); + const selectedMergeMethodLabel = PULL_REQUEST_MERGE_METHOD_LABELS[selectedMergeMethod]; const pendingAutoMergeLabel = `Auto-merge (${selectedMergeMethodLabel.toLowerCase()})`; const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; // Only an outright yes arms it. A host that reports nothing has not said the merge is already @@ -1249,7 +1287,7 @@ export function PullRequestDetailPanel({ const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; const armedMergeMethod = detail?.autoMergeMethod; const armedAutoMergeLabel = armedMergeMethod - ? `Auto-merge (${MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` + ? `Auto-merge (${PULL_REQUEST_MERGE_METHOD_LABELS[armedMergeMethod].toLowerCase()})` : "Auto-merge"; const workflowApprovalsRequired = detail?.state === "open" ? (detail.workflowApprovalsRequired ?? 0) : 0; @@ -1787,17 +1825,24 @@ export function PullRequestDetailPanel({ ) : null} - setMergeMethod(method as PullRequestMergeMethod) - } + onValueChange={(method) => { + const selectedMethod = method as PullRequestMergeMethod; + setMergeMethod(selectedMethod); + setLastSelectedMergeMethod(selectedMethod); + }} > {allowedMergeMethods.map((method) => ( - + {/* The radio item lays its children out as one block, so the icon and the label need their own row to share a line. */} - {MERGE_METHOD_LABELS[method]} + {PULL_REQUEST_MERGE_METHOD_LABELS[method]} ))} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index d2c9c45231..0f150f6724 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -37,6 +37,7 @@ import { resolvePullRequestPrimaryControl, shouldRefreshPullRequestActivity, resolveBaseFreshness, + resolvePullRequestMergeMethod, buildPullRequestTimeline, editPullRequestThreadComment, writePullRequestDetailSnapshot, @@ -85,6 +86,21 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; +describe("pull request merge method", () => { + it("uses the current choice, then the project default, then the last choice", () => { + expect( + resolvePullRequestMergeMethod(["merge", "squash", "rebase"], null, "squash", "rebase"), + ).toBe("squash"); + expect( + resolvePullRequestMergeMethod(["merge", "squash", "rebase"], "rebase", "squash", "merge"), + ).toBe("rebase"); + expect(resolvePullRequestMergeMethod(["merge", "rebase"], null, "squash", "rebase")).toBe( + "rebase", + ); + expect(resolvePullRequestMergeMethod(["squash"], null, "merge", "rebase")).toBe("squash"); + }); +}); + describe("pull request activity refresh", () => { const first = { key: "project:acme/web#7", diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index d23a876ff6..fe9bddacce 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -11,6 +11,7 @@ import { type PullRequestCommit, type PullRequestDetailView, type PullRequestMergeability, + type PullRequestMergeMethod, type PullRequestReaction, type PullRequestReviewThread, type PullRequestState, @@ -21,6 +22,24 @@ import { import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; +export const PULL_REQUEST_MERGE_METHOD_LABELS: Record = { + merge: "Merge", + squash: "Squash and merge", + rebase: "Rebase and merge", +}; + +export function resolvePullRequestMergeMethod( + allowed: ReadonlyArray, + current: PullRequestMergeMethod | null, + projectDefault: PullRequestMergeMethod | undefined, + lastSelected: PullRequestMergeMethod, +): PullRequestMergeMethod { + for (const method of [current, projectDefault, lastSelected]) { + if (method && allowed.includes(method)) return method; + } + return allowed[0] ?? "merge"; +} + const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const bitbucketRepositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index c9d92a359d..9c3031b9e7 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -21,6 +21,7 @@ import { type ResolvedKeybindingsConfig, type ServerSettings, type ProviderDriverKind, + type PullRequestMergeMethod, type SidebarProjectGroupingMode, type T3ProjectFileScript, type ThreadEnvMode, @@ -79,6 +80,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { ProjectFavicon } from "../ProjectFavicon"; +import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, @@ -480,6 +482,19 @@ function ProjectDetail({ setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); + const mergeMethodOverrides = useClientSettings( + (settings) => settings.pullRequestMergeMethodOverrides, + ); + const projectMergeMethod = mergeMethodOverrides[group.projectKey]; + const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => { + const nextOverrides = { ...mergeMethodOverrides }; + if (method === null) { + delete nextOverrides[group.projectKey]; + } else { + nextOverrides[group.projectKey] = method; + } + updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); + }; const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -999,6 +1014,42 @@ function ProjectDetail({
} /> + setProjectMergeMethod(null)} + /> + ) : null + } + control={ + + } + /> = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", ...overrides, }; } @@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => { }); describe("parsePersistedState", () => { + it("hydrates the last selected pull request merge method", () => { + const parsed = parsePersistedState({ + pullRequestMergeMethod: "squash", + }); + const invalid = parsePersistedState({ + pullRequestMergeMethod: "fast-forward", + }); + + expect(parsed.pullRequestMergeMethod).toBe("squash"); + expect(invalid.pullRequestMergeMethod).toBe("merge"); + }); + it("hydrates raw UI-owned state without server entities", () => { const parsed = parsePersistedState({ projectExpandedById: { @@ -189,6 +202,7 @@ describe("parsePersistedState", () => { }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", sidebarProjectScopeKey: null, + pullRequestMergeMethod: "merge", threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => { "turn-2": true, }, }, + pullRequestMergeMethod: "merge", }); expect(parsePersistedState(persisted)).toEqual({ ...state, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index b14ce917c8..e82c86f264 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,4 +1,5 @@ import { Debouncer } from "@tanstack/react-pacer"; +import type { PullRequestMergeMethod } from "@t3tools/contracts"; import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; @@ -29,6 +30,7 @@ export interface PersistedUiState { sidebarProjectScopeKey?: string | null; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; + pullRequestMergeMethod?: string; } export interface UiProjectState { @@ -49,7 +51,12 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {} +export interface UiPullRequestState { + pullRequestMergeMethod: PullRequestMergeMethod; +} + +export interface UiState + extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} const initialState: UiState = { projectExpandedById: {}, @@ -58,6 +65,7 @@ const initialState: UiState = { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, + pullRequestMergeMethod: "merge", }; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; @@ -109,6 +117,10 @@ function sanitizeTimestampRecord(value: unknown): Record { ); } +function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod { + return value === "merge" || value === "squash" || value === "rebase"; +} + export function parsePersistedState(parsed: PersistedUiState): UiState { const projectExpandedById = parsed.projectExpandedById === undefined @@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { : {}, defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), + pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) + ? parsed.pullRequestMergeMethod + : initialState.pullRequestMergeMethod, }; } @@ -216,6 +231,7 @@ export function persistState(state: UiState): void { sidebarProjectScopeKey: state.sidebarProjectScopeKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, + pullRequestMergeMethod: state.pullRequestMergeMethod, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -324,6 +340,12 @@ export function setSidebarProjectScopeKey(state: UiState, projectKey: string | n }; } +function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { + return state.pullRequestMergeMethod === method + ? state + : { ...state, pullRequestMergeMethod: method }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -407,6 +429,7 @@ interface UiStateStore extends UiState { setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setSidebarProjectScopeKey: (projectKey: string | null) => void; + setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -427,6 +450,7 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setSidebarProjectScopeKey: (projectKey) => set((state) => setSidebarProjectScopeKey(state, projectKey)), + setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index be7f5d1f35..670df4397a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -428,6 +428,25 @@ describe("ServerSettings Prime Agent provider", () => { }); }); +describe("ClientSettings pull request merge methods", () => { + it("defaults to no project overrides and accepts supported methods", () => { + expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({}); + expect( + decodeClientSettingsPatch({ + pullRequestMergeMethodOverrides: { project: "squash" }, + }).pullRequestMergeMethodOverrides, + ).toEqual({ project: "squash" }); + }); + + it("rejects unsupported project merge methods", () => { + expect(() => + decodeClientSettingsPatch({ + pullRequestMergeMethodOverrides: { project: "fast-forward" }, + }), + ).toThrow(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0f0a3e9b93..58005e809a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -31,6 +31,7 @@ import { ProviderInstanceId, type ProviderDriverKind, } from "./providerInstance.ts"; +import { PullRequestMergeMethod } from "./pullRequest.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -301,6 +302,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + pullRequestMergeMethodOverrides: Schema.Record( + TrimmedNonEmptyString, + PullRequestMergeMethod, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1369,6 +1374,9 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + pullRequestMergeMethodOverrides: Schema.optionalKey( + Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod), + ), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), From 0ff2855f831b4a157918b5a6a9e885518b4a0a8f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 03:29:46 -0700 Subject: [PATCH 08/15] refactor(web): pass the project record to ProjectFavicon so icons cannot drift ProjectFavicon takes one project prop, so the saved title, favicon path and icon override travel together on every surface. Pylon adaptations: the new-thread project picker (DraftHeroHeadline) now passes the project group instead of its display name; the pull request project filter keeps Pylon's saved-title field so a duplicate project whose list label gains a server or path suffix still shows the sidebar's icon. Adopted from eb115063634c416c6362cc407f8572cb0c136ddf (#10714) --- apps/web/src/components/ChatView.tsx | 5 +- apps/web/src/components/CommandPalette.tsx | 36 +--- apps/web/src/components/LegacySidebar.tsx | 8 +- .../src/components/ProjectFavicon.test.tsx | 52 ++--- apps/web/src/components/ProjectFavicon.tsx | 45 ++-- apps/web/src/components/Sidebar.tsx | 200 ++++-------------- .../src/components/ThreadCommandSubtitle.tsx | 20 +- apps/web/src/components/chat/ChatHeader.tsx | 24 +-- .../src/components/chat/DraftHeroHeadline.tsx | 9 +- .../pullRequest/PullRequestListFilters.tsx | 49 ++--- .../settings/ProjectSettingsPanel.tsx | 9 +- .../components/settings/ProjectsSettings.tsx | 11 +- .../components/settings/SettingsPanels.tsx | 25 +-- 13 files changed, 137 insertions(+), 356 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 12ad958e7e..ba46b8487f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9143,10 +9143,7 @@ export default function ChatView(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - activeProjectName={activeProject?.title} - activeProjectCwd={activeProject?.workspaceRoot ?? null} - activeProjectFaviconPath={activeProject?.faviconPath ?? null} - activeProjectIcon={activeProject?.projectIcon ?? null} + activeProject={activeProject} openInCwd={gitCwd} activeProjectScripts={activeProjectScripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index bb6166d558..c95944ac71 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -186,16 +186,7 @@ import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; function projectFavicon(project: Project) { - return ( - - ); + return ; } function getEnvironmentBrowsePlatform(os: string | null | undefined): string { @@ -969,18 +960,8 @@ function OpenCommandPaletteDialog(props: { new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); - const projectFaviconPathById = useMemo( - () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), - [projects], - ); - const projectIconByKey = useMemo( - () => - new Map( - projects.map( - (project) => - [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const, - ), - ), + const projectByKey = useMemo( + () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); const projectTitleById = useMemo( @@ -1216,12 +1197,7 @@ function OpenCommandPaletteDialog(props: { ) ?? null; return ( )} - + diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 98458cfa2d..854301bee7 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -66,7 +66,14 @@ vi.mock("../state/assets", () => ({ }, })); -import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +function makeProject( + overrides: Partial & + Pick, +): ProjectFaviconProject { + return { environmentId: "environment-test" as EnvironmentId, ...overrides }; +} type ProjectFaviconImageProps = { readonly cacheKey: string; @@ -91,9 +98,7 @@ function resolveImageComponent(): { } { hooks.beginRender(); const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace-test", - projectName: "workspace-test", + project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }), }) as ReactElement; hooks.reset(); @@ -121,9 +126,7 @@ describe("ProjectFavicon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/analytics-db", - projectName: "analytics-db", + project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), }) as ReactElement<{ readonly colorClassName?: string; readonly emoji?: string; @@ -139,9 +142,7 @@ describe("ProjectFavicon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/agent-runtime", - projectName: "agent-runtime", + project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), }) as ReactElement<{ readonly colorClassName?: string; readonly emoji?: string; @@ -155,11 +156,12 @@ describe("ProjectFavicon", () => { it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/test", - projectName: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + project: makeProject({ + workspaceRoot: "/workspace/test", + title: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + }), }) as ReactElement<{ readonly children: ReactElement<{ readonly children: ReactElement<{ readonly name: string; readonly className: string }>; @@ -174,11 +176,12 @@ describe("ProjectFavicon", () => { it("renders a saved emoji ahead of an uploaded favicon", () => { const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/test", - projectName: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "emoji", emoji: "🦄" }, + project: makeProject({ + workspaceRoot: "/workspace/test", + title: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🦄" }, + }), }) as ReactElement<{ readonly emoji: string }>; expect(element.props.emoji).toBe("🦄"); @@ -208,10 +211,11 @@ describe("ProjectFavicon", () => { it("requests a saved favicon path when one is set", () => { ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace-test", - projectName: "workspace-test", - faviconPath: "brand/icon.svg", + project: makeProject({ + workspaceRoot: "/workspace-test", + title: "workspace-test", + faviconPath: "brand/icon.svg", + }), }); expect(testState.lastTarget).toMatchObject({ diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index c121f8b449..15597e786c 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,5 @@ -import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import type { ProjectIconColor } from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, @@ -94,21 +95,33 @@ const PROJECT_ICON_COLOR_BY_NAME: Record = { web: "sky", }; +// The slice of a project that decides its icon. Every surface must pass the +// project record itself (or a snapshot spread from it) so the saved title, favicon +// and icon override always travel together. Passing a display label as the title +// changes the automatic icon, which is how the command palette drifted once. +export type ProjectFaviconProject = Pick< + EnvironmentProject, + "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" +>; + export function ProjectFavicon(input: { - environmentId: EnvironmentId; - cwd: string; - projectName: string; - faviconPath?: string | null | undefined; - projectIcon?: ProjectIconOverride | null | undefined; + project: ProjectFaviconProject; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAtomValue(projectFaviconUrlAtom(input)); - if (input.projectIcon?.kind === "emoji") { - return ; + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); + if (project.projectIcon?.kind === "emoji") { + return ; } - if (input.projectIcon?.kind === "lucide") { - const colorClassName = projectIconColorClassName(input.projectIcon.color); + if (project.projectIcon?.kind === "lucide") { + const colorClassName = projectIconColorClassName(project.projectIcon.color); const iconClassName = cn( "inline-flex size-3.5 shrink-0 items-center justify-center", colorClassName, @@ -118,7 +131,7 @@ export function ProjectFavicon(input: {