authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-06 02:32:48-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:32:48-03:00
log9f40b2f563c662b43887b11ff99c466fe871c1af
tree34d644f558304f7b09335a4b44a7aa890b22531f
parentb972f1c1dfc827c2fb074af92f52a9f8fc1fcda5
signature Signed by PGP key B5690EEEBB952194

feat(settings): add shared project defaults and scoped overrides (#9754)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

38 files changed, 2236 insertions(+), 646 deletions(-)

apps/mobile/src/features/threads/ThreadRouteScreen.tsx+18-3
...@@ -7,12 +7,21 @@ import {...@@ -7,12 +7,21 @@ import {
7} from "@react-navigation/native";7} from "@react-navigation/native";
8import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";8import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts";10import {
11 DEFAULT_SERVER_SETTINGS,
12 EnvironmentId,
13 ThreadId,
14 type ProjectScript,
15} from "@t3tools/contracts";
11import {16import {
12 requestOlderThreadTurns,17 requestOlderThreadTurns,
13 threadHasOlderTurns,18 threadHasOlderTurns,
14} from "@t3tools/client-runtime/state/threads";19} from "@t3tools/client-runtime/state/threads";
15import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";20import {
21 projectScriptCwd,
22 projectScriptRuntimeEnv,
23 resolveProjectScripts,
24} from "@t3tools/shared/projectScripts";
16import { Platform, ScrollView, View } from "react-native";25import { Platform, ScrollView, View } from "react-native";
17import { useSafeAreaInsets } from "react-native-safe-area-context";26import { useSafeAreaInsets } from "react-native-safe-area-context";
18import { useWorkspaceState } from "../../state/workspace";27import { useWorkspaceState } from "../../state/workspace";
...@@ -627,7 +636,12 @@ function ThreadRouteContent(...@@ -627,7 +636,12 @@ function ThreadRouteContent(
627 gitOperationLabel: gitState.gitOperationLabel,636 gitOperationLabel: gitState.gitOperationLabel,
628 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),637 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),
629 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),638 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
630 projectScripts: selectedThreadProject?.scripts ?? [],639 projectScripts: selectedThreadProject
640 ? resolveProjectScripts(
641 routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
642 selectedThreadProject,
643 )
644 : [],
631 terminalSessions: terminalMenuSessions,645 terminalSessions: terminalMenuSessions,
632 showDirectFileControl: layout.usesSplitView,646 showDirectFileControl: layout.usesSplitView,
633 onOpenTerminal: handleOpenTerminal,647 onOpenTerminal: handleOpenTerminal,
...@@ -819,6 +833,7 @@ function ThreadRouteContent(...@@ -819,6 +833,7 @@ function ThreadRouteContent(
819 <>833 <>
820 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}834 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}
821 <NativeStackScreenOptions835 <NativeStackScreenOptions
836 optionsVersion={threadGitControlProps.projectScripts}
822 options={{837 options={{
823 // Android draws its own in-flow header (AndroidScreenHeader below);838 // Android draws its own in-flow header (AndroidScreenHeader below);
824 // the native stack header stays iOS-only.839 // the native stack header stays iOS-only.
apps/mobile/src/features/threads/new-task-flow-provider.tsx+3-1
...@@ -427,7 +427,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -427,7 +427,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
427 );427 );
428 const projectDefaultModelSelection = resolveDefaultableModelSelection(428 const projectDefaultModelSelection = resolveDefaultableModelSelection(
429 selectedEnvironmentServerConfig,429 selectedEnvironmentServerConfig,
430 selectedProject?.defaultModelSelection ?? null,430 selectedProject?.defaultModelSelection ??
431 selectedEnvironmentServerConfig?.settings.defaultModelSelection ??
432 null,
431 );433 );
432 const storedStickyModelSelection = useStickyComposerModelSelection();434 const storedStickyModelSelection = useStickyComposerModelSelection();
433 const stickyModelSelection = resolveDefaultableModelSelection(435 const stickyModelSelection = resolveDefaultableModelSelection(
apps/server/src/project/ProjectSetupScriptRunner.test.ts+61
...@@ -6,6 +6,7 @@ import * as Option from "effect/Option";...@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
77
8import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";8import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
9import * as ServerSettings from "../serverSettings.ts";
9import * as TerminalManager from "../terminal/Manager.ts";10import * as TerminalManager from "../terminal/Manager.ts";
10import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";11import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
1112
...@@ -68,13 +69,73 @@ const makeTerminalManagerLayer = (...@@ -68,13 +69,73 @@ const makeTerminalManagerLayer = (
68const testLayer = (69const testLayer = (
69 project: OrchestrationProject,70 project: OrchestrationProject,
70 terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">,71 terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">,
72 settings = ServerSettings.layerTest(),
71) =>73) =>
72 ProjectSetupScriptRunner.layer.pipe(74 ProjectSetupScriptRunner.layer.pipe(
73 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),75 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
74 Layer.provideMerge(makeTerminalManagerLayer(terminal)),76 Layer.provideMerge(makeTerminalManagerLayer(terminal)),
77 Layer.provide(settings),
75 );78 );
7679
77describe("ProjectSetupScriptRunner", () => {80describe("ProjectSetupScriptRunner", () => {
81 it.effect("runs the inherited machine setup action in the checkout's worktree", () => {
82 const open = vi.fn(() =>
83 Effect.succeed({
84 threadId: "thread-1",
85 terminalId: "setup-default-setup",
86 cwd: "/repo/worktrees/a",
87 worktreePath: "/repo/worktrees/a",
88 status: "running" as const,
89 pid: 123,
90 history: "",
91 exitCode: null,
92 exitSignal: null,
93 label: "setup-default-setup",
94 updatedAt: "2026-01-01T00:00:00.000Z",
95 }),
96 );
97 const write = vi.fn(() => Effect.void);
98 return Effect.gen(function* () {
99 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
100 const result = yield* runner.runForThread({
101 threadId: "thread-1",
102 projectId: "project-1",
103 worktreePath: "/repo/worktrees/a",
104 });
105 expect(result).toMatchObject({ status: "started", scriptId: "default-setup" });
106 expect(open).toHaveBeenCalledWith({
107 threadId: "thread-1",
108 terminalId: "setup-default-setup",
109 cwd: "/repo/worktrees/a",
110 worktreePath: "/repo/worktrees/a",
111 env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" },
112 });
113 expect(write).toHaveBeenCalledWith({
114 threadId: "thread-1",
115 terminalId: "setup-default-setup",
116 data: "npm install\r",
117 });
118 }).pipe(
119 Effect.provide(
120 testLayer(
121 makeProject([]),
122 { open, write },
123 ServerSettings.layerTest({
124 defaultProjectScripts: [
125 {
126 id: "default-setup",
127 name: "Setup",
128 command: "npm install",
129 icon: "configure",
130 runOnWorktreeCreate: true,
131 },
132 ],
133 }),
134 ),
135 ),
136 );
137 });
138
78 it.effect("returns no-script when no setup script exists", () => {139 it.effect("returns no-script when no setup script exists", () => {
79 const open = vi.fn(() => Effect.die("unexpected open"));140 const open = vi.fn(() => Effect.die("unexpected open"));
80 const write = vi.fn(() => Effect.die("unexpected write"));141 const write = vi.fn(() => Effect.die("unexpected write"));
apps/server/src/project/ProjectSetupScriptRunner.ts+19-3
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { ProjectId } from "@t3tools/contracts";1import { ProjectId } from "@t3tools/contracts";
2import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";2import {
3 projectScriptRuntimeEnv,
4 resolveProjectScripts,
5 setupProjectScript,
6} from "@t3tools/shared/projectScripts";
3import * as Context from "effect/Context";7import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
...@@ -7,6 +11,7 @@ import * as Option from "effect/Option";...@@ -7,6 +11,7 @@ import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
812
9import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";13import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
14import * as ServerSettings from "../serverSettings.ts";
10import * as TerminalManager from "../terminal/Manager.ts";15import * as TerminalManager from "../terminal/Manager.ts";
1116
12export interface ProjectSetupScriptRunnerResultNoScript {17export interface ProjectSetupScriptRunnerResultNoScript {
...@@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass<Pr...@@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass<Pr
40 projectId: Schema.optional(Schema.String),45 projectId: Schema.optional(Schema.String),
41 projectCwd: Schema.optional(Schema.String),46 projectCwd: Schema.optional(Schema.String),
42 worktreePath: Schema.String,47 worktreePath: Schema.String,
43 operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]),48 operation: Schema.Literals(["resolveProject", "readSettings", "openTerminal", "writeCommand"]),
44 cause: Schema.Defect(),49 cause: Schema.Defect(),
45 },50 },
46) {51) {
...@@ -81,6 +86,7 @@ export class ProjectSetupScriptRunner extends Context.Service<...@@ -81,6 +86,7 @@ export class ProjectSetupScriptRunner extends Context.Service<
81export const make = Effect.gen(function* () {86export const make = Effect.gen(function* () {
82 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;87 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
83 const terminalManager = yield* TerminalManager.TerminalManager;88 const terminalManager = yield* TerminalManager.TerminalManager;
89 const serverSettings = yield* ServerSettings.ServerSettingsService;
8490
85 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(91 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
86 "ProjectSetupScriptRunner.runForThread",92 "ProjectSetupScriptRunner.runForThread",
...@@ -124,7 +130,17 @@ export const make = Effect.gen(function* () {...@@ -124,7 +130,17 @@ export const make = Effect.gen(function* () {
124 return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);130 return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);
125 }131 }
126132
127 const script = setupProjectScript(project.scripts);133 const settings = yield* serverSettings.getSettings.pipe(
134 Effect.mapError(
135 (cause) =>
136 new ProjectSetupScriptOperationError({
137 ...errorContext,
138 operation: "readSettings",
139 cause,
140 }),
141 ),
142 );
143 const script = setupProjectScript(resolveProjectScripts(settings, project));
128 if (!script) {144 if (!script) {
129 return {145 return {
130 status: "no-script",146 status: "no-script",
apps/server/src/provider/Layers/ProviderService.test.ts+80-2
...@@ -19,6 +19,8 @@ import {...@@ -19,6 +19,8 @@ import {
19 EnvironmentId,19 EnvironmentId,
20 EventId,20 EventId,
21 MessageId,21 MessageId,
22 OrchestrationThreadShell,
23 ProjectId,
22 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,24 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
23 ProviderDriverKind,25 ProviderDriverKind,
24 ProviderInstanceId,26 ProviderInstanceId,
...@@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts";...@@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts";
75import * as ServerSettings from "../../serverSettings.ts";77import * as ServerSettings from "../../serverSettings.ts";
76import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";78import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
77import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";79import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
80import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
7881
79const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest();82const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
80const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe(83const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe(
...@@ -4302,10 +4305,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {...@@ -4302,10 +4305,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {
4302 );4305 );
4303});4306});
43044307
4308const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
4309
4305describe("agent browser access", () => {4310describe("agent browser access", () => {
4306 const revokedThreads: Array<ThreadId> = [];4311 const revokedThreads: Array<ThreadId> = [];
4312 const projectId = ProjectId.make("project-browser-access");
43074313
4308 const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) =>4314 const startSessionWith = (
4315 enableAgentBrowserAccess: boolean,
4316 threadId: ThreadId,
4317 projectOverride?: boolean,
4318 ) =>
4309 Effect.gen(function* () {4319 Effect.gen(function* () {
4310 const issued: Array<ThreadId> = [];4320 const issued: Array<ThreadId> = [];
4311 const codex = makeFakeCodexAdapter();4321 const codex = makeFakeCodexAdapter();
...@@ -4319,6 +4329,49 @@ describe("agent browser access", () => {...@@ -4319,6 +4329,49 @@ describe("agent browser access", () => {
4319 const directoryLayer = ProviderSessionDirectoryLive.pipe(4329 const directoryLayer = ProviderSessionDirectoryLive.pipe(
4320 Layer.provide(runtimeRepositoryLayer),4330 Layer.provide(runtimeRepositoryLayer),
4321 );4331 );
4332 const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {
4333 getImportedAgentSessionSources: () => Effect.die("unused"),
4334 getUserInputActivity: () => Effect.die("unused"),
4335 getCommandReadModel: () => Effect.die("unused"),
4336 getSnapshot: () => Effect.die("unused"),
4337 getShellSnapshot: () => Effect.die("unused"),
4338 getArchivedShellSnapshot: () => Effect.die("unused"),
4339 getSnapshotSequence: () => Effect.die("unused"),
4340 getCounts: () => Effect.die("unused"),
4341 getEventReplayStats: () => Effect.die("unused"),
4342 getActiveProjectByWorkspaceRoot: () => Effect.die("unused"),
4343 getProjectShellById: () => Effect.die("unused"),
4344 getFirstActiveThreadIdByProjectId: () => Effect.die("unused"),
4345 getThreadCheckpointContext: () => Effect.die("unused"),
4346 getFullThreadDiffContext: () => Effect.die("unused"),
4347 getThreadRuntimeContext: () => Effect.die("unused"),
4348 getThreadShellById: (requestedThreadId) =>
4349 Effect.gen(function* () {
4350 assert.equal(requestedThreadId, threadId);
4351 return Option.some(
4352 yield* decodeBrowserAccessThreadShell({
4353 id: threadId,
4354 projectId,
4355 title: "Browser access test",
4356 modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"),
4357 runtimeMode: "full-access",
4358 branch: null,
4359 worktreePath: null,
4360 latestTurn: null,
4361 createdAt: "2026-01-01T00:00:00.000Z",
4362 updatedAt: "2026-01-01T00:00:00.000Z",
4363 session: null,
4364 latestUserMessageAt: null,
4365 hasPendingApprovals: false,
4366 hasPendingUserInput: false,
4367 hasActionableProposedPlan: false,
4368 }),
4369 );
4370 }).pipe(Effect.orDie),
4371 getThreadDetailById: () => Effect.die("unused"),
4372 getThreadDetailSnapshot: () => Effect.die("unused"),
4373 searchThreads: () => Effect.die("unused"),
4374 });
4322 const providerLayer = makeProviderServiceLive({4375 const providerLayer = makeProviderServiceLive({
4323 issueMcpCredential: (request) =>4376 issueMcpCredential: (request) =>
4324 Effect.sync(() => {4377 Effect.sync(() => {
...@@ -4329,7 +4382,14 @@ describe("agent browser access", () => {...@@ -4329,7 +4382,14 @@ describe("agent browser access", () => {
4329 }).pipe(4382 }).pipe(
4330 Layer.provide(providerAdapterLayer),4383 Layer.provide(providerAdapterLayer),
4331 Layer.provide(directoryLayer),4384 Layer.provide(directoryLayer),
4332 Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })),4385 Layer.provide(projectionLayer),
4386 Layer.provide(
4387 ServerSettings.ServerSettingsService.layerTest({
4388 enableAgentBrowserAccess,
4389 projectAgentBrowserAccessOverrides:
4390 projectOverride === undefined ? {} : { [projectId]: projectOverride },
4391 }),
4392 ),
4333 Layer.provide(serverConfigTestLayer),4393 Layer.provide(serverConfigTestLayer),
4334 Layer.provide(AnalyticsService.layerTest),4394 Layer.provide(AnalyticsService.layerTest),
4335 Layer.provide(4395 Layer.provide(
...@@ -4387,4 +4447,22 @@ describe("agent browser access", () => {...@@ -4387,4 +4447,22 @@ describe("agent browser access", () => {
4387 assert.deepEqual(issued, [threadId]);4447 assert.deepEqual(issued, [threadId]);
4388 }).pipe(Effect.provide(NodeServices.layer)),4448 }).pipe(Effect.provide(NodeServices.layer)),
4389 );4449 );
4450
4451 it.effect("withholds and revokes MCP credentials when the project disables browser access", () =>
4452 Effect.gen(function* () {
4453 const threadId = asThreadId("thread-project-browser-off");
4454 revokedThreads.length = 0;
4455 const issued = yield* startSessionWith(true, threadId, false);
4456 assert.deepEqual(issued, []);
4457 assert.deepEqual(revokedThreads, [threadId]);
4458 }).pipe(Effect.provide(NodeServices.layer)),
4459 );
4460
4461 it.effect("requests an MCP credential when the project overrides browser access to on", () =>
4462 Effect.gen(function* () {
4463 const threadId = asThreadId("thread-project-browser-on");
4464 const issued = yield* startSessionWith(false, threadId, true);
4465 assert.deepEqual(issued, [threadId]);
4466 }).pipe(Effect.provide(NodeServices.layer)),
4467 );
4390});4468});
apps/server/src/provider/Layers/ProviderService.ts+19-3
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";32import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
33import { causeErrorTag } from "@t3tools/shared/observability";33import { causeErrorTag } from "@t3tools/shared/observability";
34import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";34import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
35import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";
35import * as DateTime from "effect/DateTime";36import * as DateTime from "effect/DateTime";
36import * as Deferred from "effect/Deferred";37import * as Deferred from "effect/Deferred";
37import * as Effect from "effect/Effect";38import * as Effect from "effect/Effect";
...@@ -72,6 +73,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";...@@ -72,6 +73,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
72import * as McpProviderSession from "../../mcp/McpProviderSession.ts";73import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
73import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";74import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
74import * as ServerSettings from "../../serverSettings.ts";75import * as ServerSettings from "../../serverSettings.ts";
76import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
75const isModelSelection = Schema.is(ModelSelection);77const isModelSelection = Schema.is(ModelSelection);
7678
77interface PendingCompaction {79interface PendingCompaction {
...@@ -323,6 +325,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -323,6 +325,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
323 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;325 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
324 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;326 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
325 const serverSettings = yield* ServerSettings.ServerSettingsService;327 const serverSettings = yield* ServerSettings.ServerSettingsService;
328 const projectionQuery = yield* Effect.serviceOption(
329 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
330 );
326 const issueMcpCredential =331 const issueMcpCredential =
327 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;332 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
328 const revokeMcpCredential =333 const revokeMcpCredential =
...@@ -714,8 +719,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -714,8 +719,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
714 * "off" silently becoming "on" would violate the user's stated choice,719 * "off" silently becoming "on" would violate the user's stated choice,
715 * whereas the reverse costs an agent one toolset and is visible immediately.720 * whereas the reverse costs an agent one toolset and is visible immediately.
716 */721 */
717 const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(722 const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")(
718 Effect.map((settings) => settings.enableAgentBrowserAccess),723 function* (threadId: ThreadId) {
724 const settings = yield* serverSettings.getSettings;
725 if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) {
726 return settings.enableAgentBrowserAccess;
727 }
728 // Provider-only runtimes may omit orchestration. An unresolved project
729 // must not bypass an explicit browser override.
730 if (Option.isNone(projectionQuery)) return false;
731 const thread = yield* projectionQuery.value.getThreadShellById(threadId);
732 if (Option.isNone(thread)) return false;
733 return resolveProjectAgentBrowserAccess(settings, thread.value.projectId);
734 },
719 Effect.catch((cause) =>735 Effect.catch((cause) =>
720 Effect.logWarning(736 Effect.logWarning(
721 "Could not read server settings; withholding agent browser access for this session.",737 "Could not read server settings; withholding agent browser access for this session.",
...@@ -726,7 +742,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -726,7 +742,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
726742
727 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>743 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
728 Effect.gen(function* () {744 Effect.gen(function* () {
729 if (!(yield* agentBrowserAccessEnabled)) {745 if (!(yield* agentBrowserAccessEnabled(threadId))) {
730 // Revoke as well as clear. Every other prepare path reaches746 // Revoke as well as clear. Every other prepare path reaches
731 // `issueActiveMcpCredential`, which revokes the thread first, so747 // `issueActiveMcpCredential`, which revokes the thread first, so
732 // skipping it here would leave a previously issued bearer token valid748 // skipping it here would leave a previously issued bearer token valid
apps/server/src/server.ts+4-2
...@@ -317,7 +317,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(...@@ -317,7 +317,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe(
317);317);
318318
319const GitManagerLayerLive = GitManager.layer.pipe(319const GitManagerLayerLive = GitManager.layer.pipe(
320 Layer.provideMerge(ProjectSetupScriptRunner.layer),320 Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))),
321 Layer.provideMerge(GitVcsDriver.layer),321 Layer.provideMerge(GitVcsDriver.layer),
322 Layer.provideMerge(SourceControlProviderRegistryLayerLive),322 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
323 Layer.provideMerge(TextGeneration.layer),323 Layer.provideMerge(TextGeneration.layer),
...@@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe(...@@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe(
353 Layer.provideMerge(353 Layer.provideMerge(
354 VcsStatusBroadcaster.layer.pipe(354 VcsStatusBroadcaster.layer.pipe(
355 Layer.provide(GitWorkflowLayerLive),355 Layer.provide(GitWorkflowLayerLive),
356 Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer),356 Layer.provide(
357 VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)),
358 ),
357 ),359 ),
358 ),360 ),
359);361);
apps/server/src/serverRuntimeStartup.test.ts+54-10
...@@ -14,6 +14,7 @@ import * as ServerConfig from "./config.ts";...@@ -14,6 +14,7 @@ import * as ServerConfig from "./config.ts";
14import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";14import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
15import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";15import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
16import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";16import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
17import * as ServerSettings from "./serverSettings.ts";
17import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";18import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
1819
19it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () =>20it.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...@@ -40,7 +41,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che
40 }),41 }),
41 } as unknown as GitVcsDriver.GitVcsDriver["Service"];42 } as unknown as GitVcsDriver.GitVcsDriver["Service"];
42 const project = (workspaceRoot: string, autoPull = true) =>43 const project = (workspaceRoot: string, autoPull = true) =>
43 ({ workspaceRoot, autoPull }) as never;44 ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never;
4445
45 yield* ServerRuntimeStartup.autoPullProjects([46 yield* ServerRuntimeStartup.autoPullProjects([
46 project("/clean"),47 project("/clean"),
...@@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che...@@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che
52 ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));53 ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
5354
54 assert.deepStrictEqual(pulled, ["/clean"]);55 assert.deepStrictEqual(pulled, ["/clean"]);
56
57 pulled.length = 0;
58 yield* ServerRuntimeStartup.autoPullProjects(
59 [project("/inherited", false), project("/opted-out"), project("/dirty", false)],
60 {
61 defaultAutoPull: true,
62 projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false },
63 },
64 ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
65 assert.deepStrictEqual(pulled, ["/inherited"]);
55 }),66 }),
56);67);
5768
...@@ -124,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -124,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
124 return Effect.gen(function* () {135 return Effect.gen(function* () {
125 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);136 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
126 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(137 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
138 Effect.provide(ServerSettings.layerTest()),
127 Effect.provideService(ServerConfig.ServerConfig, {139 Effect.provideService(ServerConfig.ServerConfig, {
128 cwd: "/tmp/startup-project",140 cwd: "/tmp/startup-project",
129 autoBootstrapProjectFromCwd: true,141 autoBootstrapProjectFromCwd: true,
...@@ -189,8 +201,19 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -189,8 +201,19 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
189 });201 });
190});202});
191203
192it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () =>204it.effect.each([
205 { existing: false, machineModel: null, projectModel: null },
206 { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null },
207 { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null },
208 { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" },
209])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) =>
193 Effect.gen(function* () {210 Effect.gen(function* () {
211 const machineSelection = machineModel
212 ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel }
213 : null;
214 const projectSelection = projectModel
215 ? { instanceId: ProviderInstanceId.make("codex"), model: projectModel }
216 : null;
194 const dispatchCalls = yield* Ref.make<217 const dispatchCalls = yield* Ref.make<
195 ReadonlyArray<{218 ReadonlyArray<{
196 readonly type: string;219 readonly type: string;
...@@ -199,6 +222,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -199,6 +222,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
199 }>222 }>
200 >([]);223 >([]);
201 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(224 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
225 Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })),
202 Effect.provideService(ServerConfig.ServerConfig, {226 Effect.provideService(ServerConfig.ServerConfig, {
203 cwd: "/tmp/startup-project",227 cwd: "/tmp/startup-project",
204 autoBootstrapProjectFromCwd: true,228 autoBootstrapProjectFromCwd: true,
...@@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
212 getSnapshotSequence: () => Effect.die("unused"),236 getSnapshotSequence: () => Effect.die("unused"),
213 getCounts: () => Effect.die("unused"),237 getCounts: () => Effect.die("unused"),
214 getEventReplayStats: () => Effect.die("unused"),238 getEventReplayStats: () => Effect.die("unused"),
215 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),239 getActiveProjectByWorkspaceRoot: () =>
240 Effect.succeed(
241 existing
242 ? Option.some({
243 id: ProjectId.make("existing-project"),
244 title: "Startup Project",
245 workspaceRoot: "/tmp/startup-project",
246 defaultModelSelection: projectSelection,
247 scripts: [],
248 createdAt: "2026-01-01T00:00:00.000Z",
249 updatedAt: "2026-01-01T00:00:00.000Z",
250 deletedAt: null,
251 })
252 : Option.none(),
253 ),
216 getProjectShellById: () => Effect.die("unused"),254 getProjectShellById: () => Effect.die("unused"),
217 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),255 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
218 getImportedAgentSessionSources: () => Effect.die("unused"),256 getImportedAgentSessionSources: () => Effect.die("unused"),
...@@ -241,18 +279,22 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -241,18 +279,22 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
241279
242 assert.equal(typeof targets.bootstrapProjectId, "string");280 assert.equal(typeof targets.bootstrapProjectId, "string");
243 assert.equal(typeof targets.bootstrapThreadId, "string");281 assert.equal(typeof targets.bootstrapThreadId, "string");
244 assert.equal(targets.bootstrapProjectCreated, true);282 assert.equal(targets.bootstrapProjectCreated, !existing);
245 assert.equal(targets.bootstrapThreadCreated, true);283 assert.equal(targets.bootstrapThreadCreated, true);
246 const commands = yield* Ref.get(dispatchCalls);284 const commands = yield* Ref.get(dispatchCalls);
247 assert.deepStrictEqual(285 assert.deepStrictEqual(
248 commands.map((command) => command.type),286 commands.map((command) => command.type),
249 ["project.create", "thread.create"],287 existing ? ["thread.create"] : ["project.create", "thread.create"],
288 );
289 if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false);
290 assert.deepStrictEqual(
291 commands.at(-1)?.modelSelection,
292 projectSelection ??
293 machineSelection ?? {
294 instanceId: ProviderInstanceId.make("codex"),
295 model: DEFAULT_MODEL,
296 },
250 );297 );
251 assert.equal("defaultModelSelection" in commands[0]!, false);
252 assert.deepStrictEqual(commands[1]?.modelSelection, {
253 instanceId: ProviderInstanceId.make("codex"),
254 model: DEFAULT_MODEL,
255 });
256 }),298 }),
257);299);
258300
...@@ -262,6 +304,7 @@ it.effect(...@@ -262,6 +304,7 @@ it.effect(
262 Effect.gen(function* () {304 Effect.gen(function* () {
263 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);305 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
264 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(306 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
307 Effect.provide(ServerSettings.layerTest()),
265 Effect.provideService(ServerConfig.ServerConfig, {308 Effect.provideService(ServerConfig.ServerConfig, {
266 cwd: "/tmp/startup-project",309 cwd: "/tmp/startup-project",
267 autoBootstrapProjectFromCwd: true,310 autoBootstrapProjectFromCwd: true,
...@@ -322,6 +365,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa...@@ -322,6 +365,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
322 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);365 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
323366
324 const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(367 const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
368 Effect.provide(ServerSettings.layerTest()),
325 Effect.provideService(ServerConfig.ServerConfig, {369 Effect.provideService(ServerConfig.ServerConfig, {
326 cwd: "/tmp/startup-project",370 cwd: "/tmp/startup-project",
327 autoBootstrapProjectFromCwd: true,371 autoBootstrapProjectFromCwd: true,
apps/server/src/serverRuntimeStartup.ts+17-4
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 CommandId,2 CommandId,
3 DEFAULT_MODEL,3 DEFAULT_MODEL,
4 DEFAULT_PROVIDER_INTERACTION_MODE,4 DEFAULT_PROVIDER_INTERACTION_MODE,
5 DEFAULT_SERVER_SETTINGS,
5 type ModelSelection,6 type ModelSelection,
6 type OrchestrationProjectShell,7 type OrchestrationProjectShell,
7 ProjectId,8 ProjectId,
...@@ -9,6 +10,7 @@ import {...@@ -9,6 +10,7 @@ import {
9 ThreadId,10 ThreadId,
10 TurnId,11 TurnId,
11} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
12import * as Cause from "effect/Cause";14import * as Cause from "effect/Cause";
13import * as Console from "effect/Console";15import * as Console from "effect/Console";
14import * as Context from "effect/Context";16import * as Context from "effect/Context";
...@@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
198 let bootstrapThreadCreated = false;200 let bootstrapThreadCreated = false;
199201
200 if (serverConfig.autoBootstrapProjectFromCwd) {202 if (serverConfig.autoBootstrapProjectFromCwd) {
203 const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings;
204 const defaultModelSelection =
205 settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
201 yield* Effect.gen(function* () {206 yield* Effect.gen(function* () {
202 const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot(207 const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot(
203 serverConfig.cwd,208 serverConfig.cwd,
...@@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
209 const createdAt = DateTime.formatIso(yield* DateTime.now);214 const createdAt = DateTime.formatIso(yield* DateTime.now);
210 nextProjectId = ProjectId.make(yield* randomUUID);215 nextProjectId = ProjectId.make(yield* randomUUID);
211 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";216 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
212 nextThreadModelSelection = getAutoBootstrapThreadModelSelection();217 nextThreadModelSelection = defaultModelSelection;
213 yield* orchestrationEngine.dispatch({218 yield* orchestrationEngine.dispatch({
214 type: "project.create",219 type: "project.create",
215 commandId: CommandId.make(yield* randomUUID),220 commandId: CommandId.make(yield* randomUUID),
...@@ -224,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {...@@ -224,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
224 nextProjectId = existingProject.value.id;229 nextProjectId = existingProject.value.id;
225 bootstrapProjectId = nextProjectId;230 bootstrapProjectId = nextProjectId;
226 nextThreadModelSelection =231 nextThreadModelSelection =
227 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();232 existingProject.value.defaultModelSelection ?? defaultModelSelection;
228 }233 }
229234
230 yield* Effect.gen(function* () {235 yield* Effect.gen(function* () {
...@@ -737,12 +742,16 @@ interface StartupOptions {...@@ -737,12 +742,16 @@ interface StartupOptions {
737742
738export const autoPullProjects = Effect.fn("autoPullProjects")(function* (743export const autoPullProjects = Effect.fn("autoPullProjects")(function* (
739 projects: ReadonlyArray<OrchestrationProjectShell>,744 projects: ReadonlyArray<OrchestrationProjectShell>,
745 settings: Pick<
746 typeof DEFAULT_SERVER_SETTINGS,
747 "defaultAutoPull" | "projectAutoPullOverrides"
748 > = DEFAULT_SERVER_SETTINGS,
740) {749) {
741 const git = yield* GitVcsDriver.GitVcsDriver;750 const git = yield* GitVcsDriver.GitVcsDriver;
742 const workspaceRoots = [751 const workspaceRoots = [
743 ...new Set(752 ...new Set(
744 projects753 projects
745 .filter((project) => project.autoPull === true)754 .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull))
746 .map((project) => project.workspaceRoot),755 .map((project) => project.workspaceRoot),
747 ),756 ),
748 ];757 ];
...@@ -813,7 +822,11 @@ export const make = (options?: StartupOptions) =>...@@ -813,7 +822,11 @@ export const make = (options?: StartupOptions) =>
813 const reactorScope = yield* Scope.make("sequential");822 const reactorScope = yield* Scope.make("sequential");
814823
815 const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe(824 const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe(
816 Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)),825 Effect.flatMap((snapshot) =>
826 serverSettings.getSettings.pipe(
827 Effect.flatMap((settings) => autoPullProjects(snapshot.projects, settings)),
828 ),
829 ),
817 Effect.catch((cause) =>830 Effect.catch((cause) =>
818 Effect.logWarning("Failed to load projects for automatic pull", { cause }),831 Effect.logWarning("Failed to load projects for automatic pull", { cause }),
819 ),832 ),
apps/server/src/vcs/VcsStatusBroadcaster.ts+12-5
...@@ -22,10 +22,12 @@ import type {...@@ -22,10 +22,12 @@ import type {
22 VcsStatusStreamEvent,22 VcsStatusStreamEvent,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import { mergeGitStatusParts } from "@t3tools/shared/git";24import { mergeGitStatusParts } from "@t3tools/shared/git";
25import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
2526
26import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";27import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
27import * as GitWorkflowService from "../git/GitWorkflowService.ts";28import * as GitWorkflowService from "../git/GitWorkflowService.ts";
28import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";29import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
30import * as ServerSettings from "../serverSettings.ts";
2931
30const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);32const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
31const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);33const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);
...@@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect(...@@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect(
151 VcsAutoPullPolicy,153 VcsAutoPullPolicy,
152 Effect.gen(function* () {154 Effect.gen(function* () {
153 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;155 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
156 const serverSettings = yield* ServerSettings.ServerSettingsService;
154 return {157 return {
155 isEnabled: (cwd: string) =>158 isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")(
156 snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe(159 function* (cwd: string) {
157 Effect.map((project) => project._tag === "Some" && project.value.autoPull === true),160 const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd);
158 Effect.orElseSucceed(() => false),161 if (project._tag === "None") return false;
159 ),162 const settings = yield* serverSettings.getSettings;
163 return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull);
164 },
165 Effect.orElseSucceed(() => false),
166 ),
160 };167 };
161 }),168 }),
162);169);
apps/web/src/components/ChatView.tsx+53-36
...@@ -53,7 +53,11 @@ import {...@@ -53,7 +53,11 @@ import {
53 createModelSelection,53 createModelSelection,
54 resolvePromptInjectedEffort,54 resolvePromptInjectedEffort,
55} from "@t3tools/shared/model";55} from "@t3tools/shared/model";
56import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";56import {
57 projectScriptCwd,
58 projectScriptRuntimeEnv,
59 resolveProjectScripts,
60} from "@t3tools/shared/projectScripts";
57import { truncate } from "@t3tools/shared/String";61import { truncate } from "@t3tools/shared/String";
58import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";62import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
59import {63import {
...@@ -276,7 +280,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi...@@ -276,7 +280,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi
276import { environmentCatalog } from "../connection/catalog";280import { environmentCatalog } from "../connection/catalog";
277import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";281import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
278import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";282import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
279import { projectEnvironment } from "../state/projects";
280import { useEnvironmentQuery } from "../state/query";283import { useEnvironmentQuery } from "../state/query";
281import {284import {
282 environmentServerConfigsAtom,285 environmentServerConfigsAtom,
...@@ -1386,7 +1389,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1386,7 +1389,9 @@ export default function ChatView(props: ChatViewProps) {
1386 [environmentId, threadId],1389 [environmentId, threadId],
1387 );1390 );
1388 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);1391 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
1389 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });1392 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {
1393 reportFailure: false,
1394 });
1390 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {1395 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
1391 reportFailure: false,1396 reportFailure: false,
1392 });1397 });
...@@ -1474,9 +1479,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -1474,9 +1479,6 @@ export default function ChatView(props: ChatViewProps) {
1474 }, [routeKind, routeThreadRef, routeThreadState]);1479 }, [routeKind, routeThreadRef, routeThreadState]);
1475 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1480 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1476 const settings = useEnvironmentSettings(environmentId);1481 const settings = useEnvironmentSettings(environmentId);
1477 // New-thread defaults live in the primary environment's settings.json (the
1478 // settings UI never writes to remote environments), so read them from the
1479 // primary server rather than the thread's environment.
1480 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);1482 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
1481 const setStickyComposerModelSelection = useComposerDraftStore(1483 const setStickyComposerModelSelection = useComposerDraftStore(
1482 (store) => store.setStickyModelSelection,1484 (store) => store.setStickyModelSelection,
...@@ -1758,10 +1760,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -1758,10 +1760,17 @@ export default function ChatView(props: ChatViewProps) {
1758 ? buildLocalDraftThread(1760 ? buildLocalDraftThread(
1759 threadId,1761 threadId,
1760 draftThread,1762 draftThread,
1761 fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,1763 fallbackDraftProject?.defaultModelSelection ??
1764 settings.defaultModelSelection ??
1765 NO_PROVIDER_MODEL_SELECTION,
1762 )1766 )
1763 : undefined,1767 : undefined,
1764 [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],1768 [
1769 draftThread,
1770 fallbackDraftProject?.defaultModelSelection,
1771 settings.defaultModelSelection,
1772 threadId,
1773 ],
1765 );1774 );
1766 // Promotion is data-driven: the draft route keeps rendering while the1775 // Promotion is data-driven: the draft route keeps rendering while the
1767 // server thread (same pre-allocated ref) starts, so live state must not1776 // server thread (same pre-allocated ref) starts, so live state must not
...@@ -1987,6 +1996,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -1987,6 +1996,12 @@ export default function ChatView(props: ChatViewProps) {
1987 [activeThread?.environmentId, activeThread?.projectId],1996 [activeThread?.environmentId, activeThread?.projectId],
1988 );1997 );
1989 const activeProject = useProject(activeProjectRef);1998 const activeProject = useProject(activeProjectRef);
1999 const activeProjectScripts = useMemo(
2000 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
2001 [activeProject, settings],
2002 );
2003 const activeProjectDefaultModelSelection =
2004 activeProject?.defaultModelSelection ?? settings.defaultModelSelection;
1990 const handleNewThreadInActiveProject = useCallback(() => {2005 const handleNewThreadInActiveProject = useCallback(() => {
1991 startNewThreadForProject(activeProjectRef, handleNewThread);2006 startNewThreadForProject(activeProjectRef, handleNewThread);
1992 }, [activeProjectRef, handleNewThread]);2007 }, [activeProjectRef, handleNewThread]);
...@@ -2035,8 +2050,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -2035,8 +2050,8 @@ export default function ChatView(props: ChatViewProps) {
2035 [activeProjectKey],2050 [activeProjectKey],
2036 );2051 );
2037 const configuredPreviewUrls = useMemo(2052 const configuredPreviewUrls = useMemo(
2038 () => getConfiguredPreviewUrls(activeProject?.scripts),2053 () => getConfiguredPreviewUrls(activeProjectScripts),
2039 [activeProject?.scripts],2054 [activeProjectScripts],
2040 );2055 );
20412056
2042 useEffect(() => {2057 useEffect(() => {
...@@ -2280,7 +2295,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -2280,7 +2295,7 @@ export default function ChatView(props: ChatViewProps) {
2280 const selectedProviderByThreadId = composerActiveProvider ?? null;2295 const selectedProviderByThreadId = composerActiveProvider ?? null;
2281 const threadProvider =2296 const threadProvider =
2282 activeThread?.modelSelection.instanceId ??2297 activeThread?.modelSelection.instanceId ??
2283 activeProject?.defaultModelSelection?.instanceId ??2298 activeProjectDefaultModelSelection?.instanceId ??
2284 null;2299 null;
2285 const lockedProvider = deriveLockedProvider({2300 const lockedProvider = deriveLockedProvider({
2286 thread: activeThread,2301 thread: activeThread,
...@@ -2514,14 +2529,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -2514,14 +2529,14 @@ export default function ChatView(props: ChatViewProps) {
2514 selectedProviderByThreadId,2529 selectedProviderByThreadId,
2515 activeThread?.session?.providerInstanceId,2530 activeThread?.session?.providerInstanceId,
2516 activeThread?.modelSelection.instanceId,2531 activeThread?.modelSelection.instanceId,
2517 activeProject?.defaultModelSelection?.instanceId,2532 activeProjectDefaultModelSelection?.instanceId,
2518 ],2533 ],
2519 lockedProvider,2534 lockedProvider,
2520 lockedInstanceId:2535 lockedInstanceId:
2521 activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId,2536 activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId,
2522 }),2537 }),
2523 [2538 [
2524 activeProject?.defaultModelSelection?.instanceId,2539 activeProjectDefaultModelSelection?.instanceId,
2525 activeThread?.modelSelection.instanceId,2540 activeThread?.modelSelection.instanceId,
2526 activeThread?.session?.providerInstanceId,2541 activeThread?.session?.providerInstanceId,
2527 lockedProvider,2542 lockedProvider,
...@@ -3652,11 +3667,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -3652,11 +3667,14 @@ export default function ChatView(props: ChatViewProps) {
3652 keybindingCommand: KeybindingCommand;3667 keybindingCommand: KeybindingCommand;
3653 }): Promise<AtomCommandResult<void, unknown>> => {3668 }): Promise<AtomCommandResult<void, unknown>> => {
3654 const updateResult = mapAtomCommandResult(3669 const updateResult = mapAtomCommandResult(
3655 await updateProject({3670 await updateProjectScriptSettings({
3656 environmentId,3671 environmentId,
3657 input: {3672 input: {
3658 projectId: input.projectId,3673 patch: {
3659 scripts: input.nextScripts,3674 projectScriptOverrides: {
3675 [input.projectId]: input.nextScripts,
3676 },
3677 },
3660 },3678 },
3661 }),3679 }),
3662 () => undefined,3680 () => undefined,
...@@ -3681,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3681,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) {
3681 }3699 }
3682 return updateResult;3700 return updateResult;
3683 },3701 },
3684 [environmentId, updateProject, upsertKeybinding],3702 [environmentId, updateProjectScriptSettings, upsertKeybinding],
3685 );3703 );
3686 const saveProjectScript = useCallback(3704 const saveProjectScript = useCallback(
3687 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {3705 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {
...@@ -3690,28 +3708,28 @@ export default function ChatView(props: ChatViewProps) {...@@ -3690,28 +3708,28 @@ export default function ChatView(props: ChatViewProps) {
3690 }3708 }
3691 const nextId = nextProjectScriptId(3709 const nextId = nextProjectScriptId(
3692 input.name,3710 input.name,
3693 activeProject.scripts.map((script) => script.id),3711 activeProjectScripts.map((script) => script.id),
3694 );3712 );
3695 const nextScript = buildProjectScript(nextId, input);3713 const nextScript = buildProjectScript(nextId, input);
3696 const nextScripts = input.runOnWorktreeCreate3714 const nextScripts = input.runOnWorktreeCreate
3697 ? [3715 ? [
3698 ...activeProject.scripts.map((script) =>3716 ...activeProjectScripts.map((script) =>
3699 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,3717 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
3700 ),3718 ),
3701 nextScript,3719 nextScript,
3702 ]3720 ]
3703 : [...activeProject.scripts, nextScript];3721 : [...activeProjectScripts, nextScript];
37043722
3705 return persistProjectScripts({3723 return persistProjectScripts({
3706 projectId: activeProject.id,3724 projectId: activeProject.id,
3707 projectCwd: activeProject.workspaceRoot,3725 projectCwd: activeProject.workspaceRoot,
3708 previousScripts: activeProject.scripts,3726 previousScripts: activeProjectScripts,
3709 nextScripts,3727 nextScripts,
3710 keybinding: input.keybinding,3728 keybinding: input.keybinding,
3711 keybindingCommand: commandForProjectScript(nextId),3729 keybindingCommand: commandForProjectScript(nextId),
3712 });3730 });
3713 },3731 },
3714 [activeProject, persistProjectScripts],3732 [activeProject, activeProjectScripts, persistProjectScripts],
3715 );3733 );
3716 const updateProjectScript = useCallback(3734 const updateProjectScript = useCallback(
3717 async (3735 async (
...@@ -3721,13 +3739,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -3721,13 +3739,13 @@ export default function ChatView(props: ChatViewProps) {
3721 if (!activeProject) {3739 if (!activeProject) {
3722 return AsyncResult.success(undefined);3740 return AsyncResult.success(undefined);
3723 }3741 }
3724 const existingScript = activeProject.scripts.find((script) => script.id === scriptId);3742 const existingScript = activeProjectScripts.find((script) => script.id === scriptId);
3725 if (!existingScript) {3743 if (!existingScript) {
3726 return AsyncResult.failure(Cause.fail(new Error("Script not found.")));3744 return AsyncResult.failure(Cause.fail(new Error("Script not found.")));
3727 }3745 }
37283746
3729 const updatedScript = buildProjectScript(existingScript.id, input);3747 const updatedScript = buildProjectScript(existingScript.id, input);
3730 const nextScripts = activeProject.scripts.map((script) =>3748 const nextScripts = activeProjectScripts.map((script) =>
3731 script.id === scriptId3749 script.id === scriptId
3732 ? updatedScript3750 ? updatedScript
3733 : input.runOnWorktreeCreate3751 : input.runOnWorktreeCreate
...@@ -3738,27 +3756,27 @@ export default function ChatView(props: ChatViewProps) {...@@ -3738,27 +3756,27 @@ export default function ChatView(props: ChatViewProps) {
3738 return persistProjectScripts({3756 return persistProjectScripts({
3739 projectId: activeProject.id,3757 projectId: activeProject.id,
3740 projectCwd: activeProject.workspaceRoot,3758 projectCwd: activeProject.workspaceRoot,
3741 previousScripts: activeProject.scripts,3759 previousScripts: activeProjectScripts,
3742 nextScripts,3760 nextScripts,
3743 keybinding: input.keybinding,3761 keybinding: input.keybinding,
3744 keybindingCommand: commandForProjectScript(scriptId),3762 keybindingCommand: commandForProjectScript(scriptId),
3745 });3763 });
3746 },3764 },
3747 [activeProject, persistProjectScripts],3765 [activeProject, activeProjectScripts, persistProjectScripts],
3748 );3766 );
3749 const deleteProjectScript = useCallback(3767 const deleteProjectScript = useCallback(
3750 async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => {3768 async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => {
3751 if (!activeProject) {3769 if (!activeProject) {
3752 return AsyncResult.success(undefined);3770 return AsyncResult.success(undefined);
3753 }3771 }
3754 const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId);3772 const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId);
37553773
3756 const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name;3774 const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name;
37573775
3758 const result = await persistProjectScripts({3776 const result = await persistProjectScripts({
3759 projectId: activeProject.id,3777 projectId: activeProject.id,
3760 projectCwd: activeProject.workspaceRoot,3778 projectCwd: activeProject.workspaceRoot,
3761 previousScripts: activeProject.scripts,3779 previousScripts: activeProjectScripts,
3762 nextScripts,3780 nextScripts,
3763 keybinding: null,3781 keybinding: null,
3764 keybindingCommand: commandForProjectScript(scriptId),3782 keybindingCommand: commandForProjectScript(scriptId),
...@@ -3780,7 +3798,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3780,7 +3798,7 @@ export default function ChatView(props: ChatViewProps) {
3780 }3798 }
3781 return result;3799 return result;
3782 },3800 },
3783 [activeProject, persistProjectScripts],3801 [activeProject, activeProjectScripts, persistProjectScripts],
3784 );3802 );
37853803
3786 const handleRuntimeModeChange = useCallback(3804 const handleRuntimeModeChange = useCallback(
...@@ -6061,7 +6079,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6061,7 +6079,7 @@ export default function ChatView(props: ChatViewProps) {
60616079
6062 const scriptId = projectScriptIdFromCommand(command);6080 const scriptId = projectScriptIdFromCommand(command);
6063 if (!scriptId || !activeProject) return;6081 if (!scriptId || !activeProject) return;
6064 const script = activeProject.scripts.find((entry) => entry.id === scriptId);6082 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
6065 if (!script) return;6083 if (!script) return;
6066 event.preventDefault();6084 event.preventDefault();
6067 event.stopPropagation();6085 event.stopPropagation();
...@@ -6072,6 +6090,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6072,6 +6090,7 @@ export default function ChatView(props: ChatViewProps) {
6072 }, [6090 }, [
6073 activeProject,6091 activeProject,
6074 activeRightPanelSurface,6092 activeRightPanelSurface,
6093 activeProjectScripts,
6075 addTerminalSurface,6094 addTerminalSurface,
6076 activeThreadRef,6095 activeThreadRef,
6077 activeThreadPinned,6096 activeThreadPinned,
...@@ -6747,7 +6766,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6747,7 +6766,7 @@ export default function ChatView(props: ChatViewProps) {
6747 const title = truncate(titleSeed);6766 const title = truncate(titleSeed);
6748 const threadCreateModelSelection = createModelSelection(6767 const threadCreateModelSelection = createModelSelection(
6749 ctxSelectedModelSelection.instanceId,6768 ctxSelectedModelSelection.instanceId,
6750 ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL,6769 ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL,
6751 ctxSelectedModelSelection.options,6770 ctxSelectedModelSelection.options,
6752 );6771 );
67536772
...@@ -7885,7 +7904,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -7885,7 +7904,7 @@ export default function ChatView(props: ChatViewProps) {
7885 activeProjectFaviconPath={activeProject?.faviconPath ?? null}7904 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
7886 activeProjectIcon={activeProject?.projectIcon ?? null}7905 activeProjectIcon={activeProject?.projectIcon ?? null}
7887 openInCwd={gitCwd}7906 openInCwd={gitCwd}
7888 activeProjectScripts={activeProject?.scripts}7907 activeProjectScripts={activeProjectScripts}
7889 preferredScriptId={7908 preferredScriptId={
7890 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null7909 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
7891 }7910 }
...@@ -8124,9 +8143,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8124,9 +8143,7 @@ export default function ChatView(props: ChatViewProps) {
8124 interactionMode={interactionMode}8143 interactionMode={interactionMode}
8125 lockedProvider={lockedProvider}8144 lockedProvider={lockedProvider}
8126 providerStatuses={providerStatuses as ServerProvider[]}8145 providerStatuses={providerStatuses as ServerProvider[]}
8127 activeProjectDefaultModelSelection={8146 activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}
8128 activeProject?.defaultModelSelection
8129 }
8130 activeThreadModelSelection={activeThread?.modelSelection}8147 activeThreadModelSelection={activeThread?.modelSelection}
8131 activeContextWindow={activeContextWindow}8148 activeContextWindow={activeContextWindow}
8132 compactThreadUnavailable={compactThreadUnavailable}8149 compactThreadUnavailable={compactThreadUnavailable}
apps/web/src/components/CommandPalette.tsx+2
...@@ -1791,6 +1791,8 @@ function OpenCommandPaletteDialog(props: {...@@ -1791,6 +1791,8 @@ function OpenCommandPaletteDialog(props: {
1791 run: async () => {1791 run: async () => {
1792 await navigate({1792 await navigate({
1793 to: item.to,1793 to: item.to,
1794 search: (previous) =>
1795 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
1794 hash: item.targetId ?? item.id,1796 hash: item.targetId ?? item.id,
1795 replace: pathname === item.to,1797 replace: pathname === item.to,
1796 hashScrollIntoView: false,1798 hashScrollIntoView: false,
apps/web/src/components/chat/DraftHeroHeadline.tsx+7-2
...@@ -149,8 +149,13 @@ export function DraftHeroHeadline({...@@ -149,8 +149,13 @@ export function DraftHeroHeadline({
149 );149 );
150 if (!hasExplicitComposerModelSelection(currentDraft)) {150 if (!hasExplicitComposerModelSelection(currentDraft)) {
151 applyStickyState(draftId);151 applyStickyState(draftId);
152 if (project.defaultModelSelection) {152 const defaultModelSelection =
153 setModelSelection(draftId, project.defaultModelSelection, {153 project.defaultModelSelection ??
154 environments.find(
155 (environment) => environment.environmentId === project.environmentId,
156 )?.serverConfig?.settings.defaultModelSelection;
157 if (defaultModelSelection) {
158 setModelSelection(draftId, defaultModelSelection, {
154 replaceOptions: true,159 replaceOptions: true,
155 });160 });
156 }161 }
apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx+1-6
...@@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation";...@@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation";
6import { useNewThreadHandler } from "../../hooks/useHandleNewThread";6import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
7import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths";7import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths";
8import { newProjectId } from "../../lib/utils";8import { newProjectId } from "../../lib/utils";
9import { resolveDefaultProviderModelSelection } from "../../providerInstances";
10import { readProjects, waitForProject } from "../../state/entities";9import { readProjects, waitForProject } from "../../state/entities";
11import { usePrimaryEnvironment } from "../../state/environments";10import { usePrimaryEnvironment } from "../../state/environments";
12import { projectEnvironment } from "../../state/projects";11import { projectEnvironment } from "../../state/projects";
...@@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() {...@@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() {
52 ) ?? null,51 ) ?? null,
53 createProject: async (environmentId, workspaceRoot) => {52 createProject: async (environmentId, workspaceRoot) => {
54 const projectId = newProjectId();53 const projectId = newProjectId();
55 const providers =
56 primaryEnvironment?.environmentId === environmentId
57 ? (primaryEnvironment.serverConfig?.providers ?? [])
58 : [];
59 const result = await createProject({54 const result = await createProject({
60 environmentId,55 environmentId,
61 input: {56 input: {
...@@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() {...@@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() {
63 title: inferProjectTitleFromPath(workspaceRoot),58 title: inferProjectTitleFromPath(workspaceRoot),
64 workspaceRoot,59 workspaceRoot,
65 createWorkspaceRootIfMissing: false,60 createWorkspaceRootIfMissing: false,
66 defaultModelSelection: resolveDefaultProviderModelSelection(providers, null),61 defaultModelSelection: null,
67 },62 },
68 });63 });
69 if (result._tag === "Failure") {64 if (result._tag === "Failure") {
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-6
...@@ -51,7 +51,6 @@ import {...@@ -51,7 +51,6 @@ import {
51} from "../../onboarding/targetEnvironment.logic";51} from "../../onboarding/targetEnvironment.logic";
52import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";52import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
53import { newProjectId, randomUUID } from "../../lib/utils";53import { newProjectId, randomUUID } from "../../lib/utils";
54import { resolveDefaultProviderModelSelection } from "../../providerInstances";
55import { agentSessionImport, agentSessionScan } from "../../state/agentSessions";54import { agentSessionImport, agentSessionScan } from "../../state/agentSessions";
56import { readProjects, useProjects } from "../../state/entities";55import { readProjects, useProjects } from "../../state/entities";
57import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";56import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
...@@ -1029,9 +1028,6 @@ function ImportStep({...@@ -1029,9 +1028,6 @@ function ImportStep({
1029 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);1028 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
1030 const environmentId = targetEnvironment?.environmentId ?? null;1029 const environmentId = targetEnvironment?.environmentId ?? null;
1031 const machineLabel = targetEnvironment?.label ?? "this machine";1030 const machineLabel = targetEnvironment?.label ?? "this machine";
1032 const providers = useAtomValue(
1033 serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)),
1034 );
1035 const scan = useEnvironmentQuery(1031 const scan = useEnvironmentQuery(
1036 environmentId === null ? null : agentSessionScan({ environmentId, input: {} }),1032 environmentId === null ? null : agentSessionScan({ environmentId, input: {} }),
1037 );1033 );
...@@ -1123,7 +1119,6 @@ function ImportStep({...@@ -1123,7 +1119,6 @@ function ImportStep({
1123 const importGeneration = importGenerationRef.current;1119 const importGeneration = importGenerationRef.current;
1124 const importedProjects = importedProjectsRef.current;1120 const importedProjects = importedProjectsRef.current;
1125 const projectAttempts = projectAttemptsRef.current;1121 const projectAttempts = projectAttemptsRef.current;
1126 const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null);
1127 // Interrupted imports are neither failures nor successes — the command was1122 // Interrupted imports are neither failures nor successes — the command was
1128 // superseded or the environment dropped — but they still didn't land, so1123 // superseded or the environment dropped — but they still didn't land, so
1129 // they must not read as "imported everything". Retries skip paths that1124 // they must not read as "imported everything". Retries skip paths that
...@@ -1164,7 +1159,7 @@ function ImportStep({...@@ -1164,7 +1159,7 @@ function ImportStep({
1164 title: candidate.title,1159 title: candidate.title,
1165 workspaceRoot: candidate.path,1160 workspaceRoot: candidate.path,
1166 createWorkspaceRootIfMissing: false,1161 createWorkspaceRootIfMissing: false,
1167 defaultModelSelection,1162 defaultModelSelection: null,
1168 },1163 },
1169 });1164 });
1170 if (1165 if (
apps/web/src/components/settings/IntegrationsSettings.test.tsx+13-1
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts";1import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRouter,
6 RouterProvider,
7} from "@tanstack/react-router";
2import { act, StrictMode, type ReactNode } from "react";8import { act, StrictMode, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";9import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
...@@ -44,13 +50,19 @@ afterEach(async () => {...@@ -44,13 +50,19 @@ afterEach(async () => {
44});50});
4551
46async function openSettings() {52async function openSettings() {
53 const router = createRouter({
54 routeTree: createRootRoute({ component: IntegrationsSettingsPanel }),
55 history: createMemoryHistory(),
56 });
57 await router.load();
47 await act(() => {58 await act(() => {
48 renderer = create(59 renderer = create(
49 <StrictMode>60 <StrictMode>
50 <IntegrationsSettingsPanel />61 <RouterProvider router={router} />
51 </StrictMode>,62 </StrictMode>,
52 );63 );
53 });64 });
65 expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined();
54}66}
5567
56describe("Integrations browser discovery", () => {68describe("Integrations browser discovery", () => {
apps/web/src/components/settings/IntegrationsSettings.tsx+10-30
...@@ -20,7 +20,6 @@ import {...@@ -20,7 +20,6 @@ import {
20 DEFAULT_BROWSER_RECORDING_FRAME_RATE,20 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
21 DEFAULT_BROWSER_VIEWPORT,21 DEFAULT_BROWSER_VIEWPORT,
22 DEFAULT_PREVIEW_APPEARANCE,22 DEFAULT_PREVIEW_APPEARANCE,
23 DEFAULT_UNIFIED_SETTINGS,
24 DEFAULT_PREVIEW_ZOOM_FACTOR,23 DEFAULT_PREVIEW_ZOOM_FACTOR,
25 FILL_PREVIEW_VIEWPORT,24 FILL_PREVIEW_VIEWPORT,
26 PREVIEW_VIEWPORT_MAX_AREA,25 PREVIEW_VIEWPORT_MAX_AREA,
...@@ -35,6 +34,7 @@ import {...@@ -35,6 +34,7 @@ import {
35 type PreviewViewportSetting,34 type PreviewViewportSetting,
36} from "@t3tools/contracts";35} from "@t3tools/contracts";
37import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";36import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
37import { Link } from "@tanstack/react-router";
38import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react";38import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react";
39import { useCallback, useRef, useState, type ReactNode } from "react";39import { useCallback, useRef, useState, type ReactNode } from "react";
4040
...@@ -86,7 +86,6 @@ import {...@@ -86,7 +86,6 @@ import {
86 persistClientSettingsUpdate,86 persistClientSettingsUpdate,
87 useClientSettings,87 useClientSettings,
88 useClientSettingsHydrated,88 useClientSettingsHydrated,
89 usePrimarySettings,
90 useUpdatePrimarySettings,89 useUpdatePrimarySettings,
91} from "~/hooks/useSettings";90} from "~/hooks/useSettings";
9291
...@@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })...@@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
552}551}
553552
554function AgentBrowserAccessSetting() {553function AgentBrowserAccessSetting() {
555 const settings = usePrimarySettings();
556 const updateSettings = useUpdatePrimarySettings();
557
558 return (554 return (
559 <SettingsRow555 <SettingsRow
560 serverScoped
561 {...searchableSetting("agent-browser-access")}556 {...searchableSetting("agent-browser-access")}
562 description="Allow agents to use the preview browser. Off hides browser tools from agents, not you."557 description="Choose whether agents can use the preview browser for all projects or a specific project."
563 status={
564 settings.enableAgentBrowserAccess
565 ? undefined
566 : "Applies to sessions started from now on; a running agent keeps the tools it was given."
567 }
568 resetAction={
569 settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? (
570 <SettingResetButton
571 label="agent browser access"
572 onClick={() =>
573 updateSettings({
574 enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
575 })
576 }
577 />
578 ) : null
579 }
580 control={558 control={
581 <Switch559 <Button
582 checked={settings.enableAgentBrowserAccess}560 render={
583 onCheckedChange={(checked) =>561 <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} />
584 updateSettings({ enableAgentBrowserAccess: Boolean(checked) })
585 }562 }
586 aria-label="Allow agent browser access"563 size="sm"
587 />564 variant="outline"
565 >
566 Project settings
567 </Button>
588 }568 }
589 />569 />
590 );570 );
apps/web/src/components/settings/ProjectActionsList.tsx created+69
...@@ -0,0 +1,69 @@
1import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { SettingsIcon } from "lucide-react";
3import { shortcutLabelForCommand } from "../../keybindings";
4import { commandForProjectScript } from "../../projectScripts";
5import { ScriptIcon } from "../projectScriptEditor";
6import { Button } from "../ui/button";
7import { SettingsRow } from "./settingsLayout";
8
9export function ProjectActionsList({
10 scripts,
11 keybindings,
12 disabled,
13 onEdit,
14}: {
15 scripts: readonly ProjectScript[];
16 keybindings: ResolvedKeybindingsConfig;
17 disabled: boolean;
18 onEdit: (script: ProjectScript) => void;
19}) {
20 if (scripts.length === 0)
21 return (
22 <p className="px-3 py-2 text-base text-muted-foreground sm:px-4 sm:text-sm">
23 No actions configured.
24 </p>
25 );
26 return scripts.map((script) => {
27 const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id));
28 return (
29 <SettingsRow
30 key={script.id}
31 className="group py-2"
32 title={
33 <span className="flex min-w-0 items-center gap-2">
34 <ScriptIcon icon={script.icon} className="size-4 shrink-0 text-muted-foreground" />
35 <span className="min-w-0 truncate">{script.name}</span>
36 {script.runOnWorktreeCreate ? (
37 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground">
38 setup
39 </span>
40 ) : null}
41 {script.previewUrl ? (
42 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground max-sm:hidden">
43 preview · desktop only
44 </span>
45 ) : null}
46 </span>
47 }
48 description={<code className="block max-w-full truncate font-mono">{script.command}</code>}
49 control={
50 <>
51 {shortcutLabel ? (
52 <span className="text-xs text-muted-foreground">{shortcutLabel}</span>
53 ) : null}
54 <Button
55 size="icon-xs"
56 variant="ghost"
57 className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
58 aria-label={`Edit ${script.name}`}
59 disabled={disabled}
60 onClick={() => onEdit(script)}
61 >
62 <SettingsIcon className="size-3.5" />
63 </Button>
64 </>
65 }
66 />
67 );
68 });
69}
apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx created+114
...@@ -0,0 +1,114 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
3import { PlusIcon } from "lucide-react";
4import { useState } from "react";
5import { useEnvironments } from "../../state/environments";
6import {
7 EMPTY_PROJECT_SCRIPT_INPUT,
8 editorRequestForScript,
9 ProjectScriptEditorDialog,
10 type ProjectScriptEditorRequest,
11} from "../projectScriptEditor";
12import { Button } from "../ui/button";
13import { ProjectActionsList } from "./ProjectActionsList";
14import { useProjectScriptSettings } from "./ProjectSettingsPanel";
15import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
16
17export function ProjectDefaultActionsSettings({
18 environmentId,
19}: {
20 environmentId: EnvironmentId | null;
21}) {
22 const { environments } = useEnvironments();
23 const targets = environments.filter(
24 (environment) =>
25 (environmentId === null || environment.environmentId === environmentId) &&
26 environment.connection.phase === "connected" &&
27 environment.serverConfig !== null,
28 );
29 const representative = targets[0]?.serverConfig;
30 const scripts = representative?.settings.defaultProjectScripts ?? [];
31 const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
32 const mixed = targets.some(
33 (target) =>
34 JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !==
35 JSON.stringify(scripts),
36 );
37 const [request, setRequest] = useState<ProjectScriptEditorRequest | null>(null);
38 const { saving, persist, submit } = useProjectScriptSettings(
39 targets.flatMap(({ environmentId, serverConfig }) =>
40 serverConfig
41 ? [
42 {
43 environmentId,
44 settings: serverConfig.settings,
45 keybindings: serverConfig.keybindings,
46 },
47 ]
48 : [],
49 ),
50 );
51
52 return (
53 <SettingsSection title="Actions">
54 <SettingsRow
55 title="Import scripts"
56 aria-disabled
57 description="Select a project to import actions from its checkout's t3.json."
58 control={
59 <Button size="xs" variant="ghost" disabled>
60 Import scripts
61 </Button>
62 }
63 />
64 <SettingsRow
65 title="Default actions"
66 description="Available in every inheriting checkout. Commands run in that checkout or its worktree."
67 resetAction={
68 targets.some(
69 (target) => (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0,
70 ) ? (
71 <SettingResetButton
72 label="default actions"
73 disabled={saving}
74 onClick={() => void persist(() => [])}
75 />
76 ) : null
77 }
78 control={
79 <Button
80 size="xs"
81 variant="outline"
82 disabled={saving || targets.length === 0}
83 onClick={() => setRequest({ scriptId: null, initial: EMPTY_PROJECT_SCRIPT_INPUT })}
84 >
85 <PlusIcon className="size-3.5" />
86 Add action
87 </Button>
88 }
89 />
90 {mixed ? (
91 <SettingsRow
92 title="Different actions across machines"
93 description="Select a machine to edit its actions. Adding an action applies to all selected connected machines."
94 />
95 ) : (
96 <ProjectActionsList
97 scripts={scripts}
98 keybindings={keybindings}
99 disabled={saving}
100 onEdit={(script) => setRequest(editorRequestForScript(script, keybindings))}
101 />
102 )}
103 <ProjectScriptEditorDialog
104 request={request}
105 scripts={scripts}
106 onSubmit={submit}
107 onDelete={(id) =>
108 void persist((current) => current.filter((script) => script.id !== id), id, null)
109 }
110 onClose={() => setRequest(null)}
111 />
112 </SettingsSection>
113 );
114}
apps/web/src/components/settings/ProjectDefaultsSettings.tsx created+474
...@@ -0,0 +1,474 @@
1import {
2 DEFAULT_CLIENT_SETTINGS,
3 DEFAULT_SERVER_SETTINGS,
4 type EnvironmentId,
5 type ModelSelection,
6 type ProviderInstanceId,
7 type ServerSettingsPatch,
8} from "@t3tools/contracts";
9import { createModelSelection } from "@t3tools/shared/model";
10import { useNavigate } from "@tanstack/react-router";
11import { useRef, useState } from "react";
12import { Trash2Icon } from "lucide-react";
13
14import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings";
15import { getCustomModelOptionsByInstance } from "../../modelSelection";
16import {
17 applyProviderInstanceSettings,
18 deriveProviderInstanceEntries,
19 resolveDefaultProviderModelSelection,
20 sortProviderInstanceEntries,
21} from "../../providerInstances";
22import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
23import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
24import { useAtomCommand } from "../../state/use-atom-command";
25import { resolveEnvModeLabel } from "../BranchToolbar.logic";
26import { ProviderModelPicker } from "../chat/ProviderModelPicker";
27import { TraitsPicker } from "../chat/TraitsPicker";
28import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
29import { toastManager } from "../ui/toast";
30import { Switch } from "../ui/switch";
31import { Button } from "../ui/button";
32import { Input } from "../ui/input";
33import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel";
34import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings";
35import { searchableSetting } from "./settingsSearch";
36import {
37 SETTINGS_PICKER_TRIGGER_CLASSNAME,
38 SettingResetButton,
39 SettingsPageContainer,
40 SettingsRow,
41 SettingsSection,
42} from "./settingsLayout";
43
44/** Defaults are written only to the machines selected on the projects settings page. */
45export function ProjectDefaultsSettings({
46 environmentId,
47}: {
48 environmentId: EnvironmentId | null;
49}) {
50 const { environments } = useEnvironments();
51 const primaryEnvironmentId = usePrimaryEnvironmentId();
52 const clientSettings = useClientSettings();
53 const updateClientSettings = useUpdateClientSettings();
54 const navigate = useNavigate();
55 const updateSettings = useAtomCommand(
56 serverEnvironment.updateSettings,
57 "project defaults update",
58 );
59 const savingRef = useRef(new Set<string>());
60 const [saving, setSaving] = useState<ReadonlySet<string>>(new Set());
61 const scoped = environments.filter(
62 (environment) => environmentId === null || environment.environmentId === environmentId,
63 );
64 const targets = scoped.filter(
65 (environment) =>
66 environment.connection.phase === "connected" && environment.serverConfig !== null,
67 );
68 const representative =
69 targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0];
70 const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS;
71 const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
72 const settings = { ...serverSettings, ...clientSettings };
73 const storedSelection = serverSettings.defaultModelSelection;
74 const selection = resolveDefaultProviderModelSelection(providers, storedSelection);
75 const entries = sortProviderInstanceEntries(
76 applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings),
77 );
78 const modelOptions = getCustomModelOptionsByInstance(
79 settings,
80 providers,
81 selection?.instanceId,
82 selection?.model,
83 );
84 const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId);
85 const mixedModel = targets.some(
86 (target) =>
87 JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !==
88 JSON.stringify(storedSelection),
89 );
90 const mixedWorkspace = targets.some(
91 (target) =>
92 target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode,
93 );
94 const mixedBrowser = targets.some(
95 (target) =>
96 target.serverConfig?.settings.enableAgentBrowserAccess !==
97 serverSettings.enableAgentBrowserAccess,
98 );
99 const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key);
100 const mixedAutoPull = targets.some(
101 (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull,
102 );
103
104 function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null {
105 const sourceEntry = entries.find((entry) => entry.instanceId === instanceId);
106 for (const target of targets) {
107 const config = target.serverConfig;
108 if (!config) continue;
109 const entry = applyProviderInstanceSettings(
110 deriveProviderInstanceEntries(config.providers),
111 config.settings,
112 ).find((candidate) => candidate.instanceId === instanceId);
113 const options = getCustomModelOptionsByInstance(
114 { ...config.settings, ...clientSettings },
115 config.providers,
116 ).get(instanceId);
117 if (
118 !entry?.enabled ||
119 !entry.isAvailable ||
120 entry.driverKind !== sourceEntry?.driverKind ||
121 !options?.some((option) => option.slug === model && !option.isUnavailable)
122 ) {
123 return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`;
124 }
125 }
126 return null;
127 }
128
129 async function save(patch: ServerSettingsPatch) {
130 const keys = Object.keys(patch);
131 if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return;
132 const nextModel = patch.defaultModelSelection;
133 const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null;
134 if (reason) {
135 toastManager.add({ type: "error", title: "Default model not saved", description: reason });
136 return;
137 }
138 for (const key of keys) savingRef.current.add(key);
139 setSaving(new Set(savingRef.current));
140 try {
141 const results = await Promise.all(
142 targets.map((target) =>
143 updateSettings({ environmentId: target.environmentId, input: { patch } }),
144 ),
145 );
146 const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure");
147 if (failedTargets.length > 0) {
148 toastManager.add({
149 type: "error",
150 title: "Project defaults not saved on every machine",
151 description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`,
152 });
153 }
154 } finally {
155 for (const key of keys) savingRef.current.delete(key);
156 setSaving(new Set(savingRef.current));
157 }
158 }
159
160 const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value });
161 return (
162 <SettingsPageContainer>
163 <SettingsSection
164 id={searchableSetting("project-defaults").id}
165 title="Project defaults"
166 hideTitle
167 >
168 <SettingsRow
169 title="Name"
170 aria-disabled
171 description="Select a project to change its name."
172 control={
173 <Input
174 size="sm"
175 className="w-full sm:w-64"
176 aria-label="Project name"
177 placeholder="Select a project"
178 disabled
179 />
180 }
181 />
182 <SettingsRow
183 title="Project icon"
184 aria-disabled
185 description="Select a project to change its icon."
186 control={
187 <div className="flex items-center gap-2">
188 <Button size="sm" variant="outline" disabled>
189 Choose icon
190 </Button>
191 <Button size="sm" variant="outline" disabled>
192 Choose file
193 </Button>
194 </div>
195 }
196 />
197 {scoped.length > targets.length || targets.length === 0 ? (
198 <p role="status" className="px-4 py-3 text-sm text-muted-foreground">
199 {targets.length === 0
200 ? "Connect a machine to change its project defaults."
201 : "Changes apply to connected machines only. Offline machines keep their current defaults."}
202 </p>
203 ) : null}
204 <SettingsRow
205 title="Model"
206 description="Default model for new threads. Projects can override it."
207 status={
208 targets.length === 0
209 ? undefined
210 : mixedModel
211 ? "Differs by machine"
212 : storedSelection === null
213 ? "Automatic"
214 : undefined
215 }
216 resetAction={
217 storedSelection !== null || mixedModel ? (
218 <SettingResetButton
219 label="default model"
220 disabled={disabled("defaultModelSelection")}
221 onClick={() => setModel(null)}
222 />
223 ) : null
224 }
225 control={
226 selection && activeEntry ? (
227 <fieldset
228 disabled={disabled("defaultModelSelection")}
229 className="flex min-w-0 flex-wrap items-center justify-end gap-1.5 disabled:opacity-50"
230 >
231 <ProviderModelPicker
232 activeInstanceId={selection.instanceId}
233 model={selection.model}
234 lockedProvider={null}
235 instanceEntries={entries}
236 modelOptionsByInstance={modelOptions}
237 disabled={disabled("defaultModelSelection")}
238 triggerVariant="outline"
239 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
240 getModelDisabledReason={modelDisabledReason}
241 onOpenProviderSetup={(instanceId) => {
242 if (representative)
243 void navigate({
244 to: "/settings/providers",
245 search: { environmentId: representative.environmentId, instanceId },
246 });
247 }}
248 onInstanceModelChange={(instanceId, model) =>
249 setModel(createModelSelection(instanceId, model))
250 }
251 />
252 {!mixedModel ? (
253 <TraitsPicker
254 provider={activeEntry.driverKind}
255 models={activeEntry.models}
256 model={selection.model}
257 prompt=""
258 onPromptChange={() => {}}
259 modelOptions={selection.options ?? []}
260 allowPromptInjectedEffort={false}
261 planModeEnabled={settings.planModeEnabled}
262 triggerVariant="outline"
263 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
264 onModelOptionsChange={(options) =>
265 setModel(createModelSelection(selection.instanceId, selection.model, options))
266 }
267 />
268 ) : null}
269 </fieldset>
270 ) : (
271 <span className="text-sm text-muted-foreground">No providers available</span>
272 )
273 }
274 />
275 <SettingsRow
276 id={searchableSetting("new-threads").id}
277 title="Workspace"
278 description="Where new threads start, unless overridden by the project or t3.json."
279 resetAction={
280 mixedWorkspace ||
281 serverSettings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? (
282 <SettingResetButton
283 label="default workspace"
284 disabled={disabled("defaultThreadEnvMode")}
285 onClick={() =>
286 void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode })
287 }
288 />
289 ) : null
290 }
291 control={
292 <Select
293 disabled={disabled("defaultThreadEnvMode")}
294 value={mixedWorkspace ? "mixed" : serverSettings.defaultThreadEnvMode}
295 onValueChange={(value) => {
296 if (value === "local" || value === "worktree")
297 void save({ defaultThreadEnvMode: value });
298 }}
299 >
300 <SelectTrigger size="sm" aria-label="Default workspace">
301 <SelectValue>
302 {targets.length === 0
303 ? "Unavailable"
304 : mixedWorkspace
305 ? "Differs by machine"
306 : resolveEnvModeLabel(serverSettings.defaultThreadEnvMode)}
307 </SelectValue>
308 </SelectTrigger>
309 <SelectPopup align="end" alignItemWithTrigger={false}>
310 <SelectItem value="local">{resolveEnvModeLabel("local")}</SelectItem>
311 <SelectItem value="worktree">{resolveEnvModeLabel("worktree")}</SelectItem>
312 </SelectPopup>
313 </Select>
314 }
315 />
316 <SettingsRow
317 title="Automatically pull"
318 description="Keeps the default branch current when the checkout has no local changes or commits. Projects can override it."
319 status={mixedAutoPull ? "Differs by machine" : undefined}
320 resetAction={
321 serverSettings.defaultAutoPull || mixedAutoPull ? (
322 <SettingResetButton
323 label="default automatic pull"
324 tooltip="Reset automatic pull to off"
325 disabled={disabled("defaultAutoPull")}
326 onClick={() => void save({ defaultAutoPull: false })}
327 />
328 ) : null
329 }
330 control={
331 <Switch
332 aria-label="Default automatic pull"
333 checked={serverSettings.defaultAutoPull}
334 disabled={disabled("defaultAutoPull")}
335 onCheckedChange={(enabled) => void save({ defaultAutoPull: enabled })}
336 />
337 }
338 />
339 <SettingsRow
340 id={searchableSetting("agent-browser-access").id}
341 title="Agent browser access"
342 description="Allow agents to use the shared browser. Projects can override it."
343 resetAction={
344 mixedBrowser ||
345 serverSettings.enableAgentBrowserAccess !==
346 DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess ? (
347 <SettingResetButton
348 label="default browser access"
349 disabled={disabled("enableAgentBrowserAccess")}
350 onClick={() =>
351 void save({
352 enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess,
353 })
354 }
355 />
356 ) : null
357 }
358 control={
359 <Select
360 disabled={disabled("enableAgentBrowserAccess")}
361 value={
362 mixedBrowser
363 ? "mixed"
364 : serverSettings.enableAgentBrowserAccess
365 ? "enabled"
366 : "disabled"
367 }
368 onValueChange={(value) => {
369 if (value === "enabled" || value === "disabled")
370 void save({ enableAgentBrowserAccess: value === "enabled" });
371 }}
372 >
373 <SelectTrigger size="sm" aria-label="Default agent browser access">
374 <SelectValue>
375 {targets.length === 0
376 ? "Unavailable"
377 : mixedBrowser
378 ? "Differs by machine"
379 : serverSettings.enableAgentBrowserAccess
380 ? "Enabled"
381 : "Disabled"}
382 </SelectValue>
383 </SelectTrigger>
384 <SelectPopup align="end" alignItemWithTrigger={false}>
385 <SelectItem value="enabled">Enabled</SelectItem>
386 <SelectItem value="disabled">Disabled</SelectItem>
387 </SelectPopup>
388 </Select>
389 }
390 />
391 </SettingsSection>
392 <SettingsSection title="Checkout">
393 <SettingsRow
394 title="Checkout"
395 aria-disabled
396 description="Select a project to choose one of its checkouts."
397 control={
398 <Select disabled>
399 <SelectTrigger size="sm" aria-label="Checkout">
400 <SelectValue placeholder="Select a project" />
401 </SelectTrigger>
402 </Select>
403 }
404 />
405 <SettingsRow
406 title="Project grouping"
407 description="Default grouping across all machines in this client. Individual checkout overrides are preserved."
408 resetAction={
409 clientSettings.sidebarProjectGroupingMode !==
410 DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode ? (
411 <SettingResetButton
412 label="default project grouping"
413 onClick={() =>
414 void updateClientSettings({
415 sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode,
416 })
417 }
418 />
419 ) : null
420 }
421 control={
422 <Select
423 value={clientSettings.sidebarProjectGroupingMode}
424 onValueChange={(value) => {
425 if (value === "repository" || value === "repository_path" || value === "separate")
426 void updateClientSettings({ sidebarProjectGroupingMode: value });
427 }}
428 >
429 <SelectTrigger size="sm" aria-label="Default project grouping">
430 <SelectValue>
431 {PROJECT_GROUPING_MODE_LABELS[clientSettings.sidebarProjectGroupingMode]}
432 </SelectValue>
433 </SelectTrigger>
434 <SelectPopup align="end" alignItemWithTrigger={false}>
435 <SelectItem value="repository">
436 {PROJECT_GROUPING_MODE_LABELS.repository}
437 </SelectItem>
438 <SelectItem value="repository_path">
439 {PROJECT_GROUPING_MODE_LABELS.repository_path}
440 </SelectItem>
441 <SelectItem value="separate">{PROJECT_GROUPING_MODE_LABELS.separate}</SelectItem>
442 </SelectPopup>
443 </Select>
444 }
445 />
446 <SettingsRow
447 title="Remove checkout"
448 aria-disabled
449 description="Select a project to remove one of its checkouts."
450 control={
451 <Button size="sm" variant="destructive-outline" disabled>
452 <Trash2Icon className="size-3.5" />
453 Remove checkout
454 </Button>
455 }
456 />
457 </SettingsSection>
458 <ProjectDefaultActionsSettings environmentId={environmentId} />
459 <SettingsSection title="Danger">
460 <SettingsRow
461 title="Remove project"
462 aria-disabled
463 description="Select a project to remove its entries and threads. Files on disk are not touched."
464 control={
465 <Button size="sm" variant="destructive-outline" disabled>
466 <Trash2Icon />
467 Remove project
468 </Button>
469 }
470 />
471 </SettingsSection>
472 </SettingsPageContainer>
473 );
474}
apps/web/src/components/settings/ProjectSettingsPanel.tsx+599-459
...@@ -12,52 +12,53 @@ import {...@@ -12,52 +12,53 @@ import {
12 deriveProjectGroupingOverrideKey,12 deriveProjectGroupingOverrideKey,
13 selectProjectGroupingSettings,13 selectProjectGroupingSettings,
14} from "../../logicalProject";14} from "../../logicalProject";
15import type {15import {
16 ContextMenuItem,16 type EnvironmentId,
17 ModelSelection,17 type ModelSelection,
18 ProjectIconOverride,18 type ProjectIconOverride,
19 ProviderDriverKind,19 type ProjectId,
20 SidebarProjectGroupingMode,20 type ProjectScript,
21 T3ProjectFileScript,21 type ResolvedKeybindingsConfig,
22 ThreadEnvMode,22 type ServerSettings,
23 type ProviderDriverKind,
24 type SidebarProjectGroupingMode,
25 type T3ProjectFileScript,
26 type ThreadEnvMode,
23} from "@t3tools/contracts";27} from "@t3tools/contracts";
24import { resolveEnvModeLabel } from "../BranchToolbar.logic";28import { resolveEnvModeLabel } from "../BranchToolbar.logic";
25import { createModelSelection } from "@t3tools/shared/model";29import { createModelSelection } from "@t3tools/shared/model";
30import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
31import {
32 projectScriptsInheritDefaults,
33 resolveProjectScripts,
34} from "@t3tools/shared/projectScripts";
26import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";35import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
27import { useCanGoBack, useNavigate } from "@tanstack/react-router";36import { useNavigate } from "@tanstack/react-router";
37import * as Equal from "effect/Equal";
28import * as Cause from "effect/Cause";38import * as Cause from "effect/Cause";
29import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";39import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";
30import {40import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
31 lazy,
32 Suspense,
33 useCallback,
34 useEffect,
35 useMemo,
36 useRef,
37 useState,
38 type MouseEvent as ReactMouseEvent,
39} from "react";
4041
41import { useComposerDraftStore } from "../../composerDraftStore";42import { useComposerDraftStore } from "../../composerDraftStore";
42import { isElectron } from "../../env";
43import {43import {
44 useClientSettings,44 useClientSettings,
45 useEnvironmentSettings,45 useEnvironmentSettings,
46 useUpdateClientSettings,46 useUpdateClientSettings,
47 usePrimarySettings,
48} from "../../hooks/useSettings";47} from "../../hooks/useSettings";
49import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
50import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";48import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
51import { shortcutLabelForCommand } from "../../keybindings";49import { ProjectActionsList } from "./ProjectActionsList";
52import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";50import { isElectron } from "../../env";
53import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";51import {
54import { readLocalApi } from "../../localApi";52 decodeProjectScriptKeybindingRule,
53 keybindingValueForCommand,
54} from "../../lib/projectScriptKeybindings";
55import {55import {
56 buildProjectScript,56 buildProjectScript,
57 commandForProjectScript,57 commandForProjectScript,
58 nextProjectScriptId,58 nextProjectScriptId,
59} from "../../projectScripts";59} from "../../projectScripts";
60import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";60import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
61import { readLocalApi } from "../../localApi";
61import {62import {
62 applyProviderInstanceSettings,63 applyProviderInstanceSettings,
63 deriveProviderInstanceEntries,64 deriveProviderInstanceEntries,
...@@ -98,16 +99,8 @@ import {...@@ -98,16 +99,8 @@ import {
98 MenuTrigger,99 MenuTrigger,
99} from "../ui/menu";100} from "../ui/menu";
100import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";101import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
101import { SidebarInset } from "../ui/sidebar";
102import { Switch } from "../ui/switch";102import { Switch } from "../ui/switch";
103import { stackedThreadToast, toastManager } from "../ui/toast";103import { stackedThreadToast, toastManager } from "../ui/toast";
104import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
105import {
106 WorkspaceBreadcrumb,
107 WorkspaceBreadcrumbItem,
108 WorkspaceBreadcrumbSeparator,
109} from "../WorkspaceBreadcrumb";
110import { WorkspacePageHeader } from "../WorkspacePageHeader";
111import {104import {
112 SETTINGS_PICKER_TRIGGER_CLASSNAME,105 SETTINGS_PICKER_TRIGGER_CLASSNAME,
113 SettingResetButton,106 SettingResetButton,
...@@ -127,14 +120,14 @@ const ProjectIconPickerDialog = lazy(() =>...@@ -127,14 +120,14 @@ const ProjectIconPickerDialog = lazy(() =>
127 })),120 })),
128);121);
129122
130const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {123export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
131 repository: "Group by repository",124 repository: "Group by repository",
132 repository_path: "Group by repository path",125 repository_path: "Group by repository path",
133 separate: "Keep separate",126 separate: "Keep separate",
134};127};
135128
136/** Logical project groups for the settings page, sorted by display name. */129/** Logical project groups for the settings page, sorted by display name. */
137function useSettingsProjectGroups(): SidebarProjectSnapshot[] {130export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
138 const projects = useProjects();131 const projects = useProjects();
139 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);132 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
140 const primaryEnvironmentId = usePrimaryEnvironmentId();133 const primaryEnvironmentId = usePrimaryEnvironmentId();
...@@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string {...@@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string {
162 return `${member.environmentId}:${member.id}`;155 return `${member.environmentId}:${member.id}`;
163}156}
164157
165export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {158export function ProjectSettingsPanel({
166 const navigate = useNavigate();159 projectKey,
167 const canGoBack = useCanGoBack();160 environmentId = null,
168 const navigateBackWithinApp = useCallback(() => {161}: {
169 if (canGoBack) {162 projectKey: string;
170 window.history.back();163 environmentId?: EnvironmentId | null;
171 return;164}) {
172 }
173 void navigate({ to: "/" });
174 }, [canGoBack, navigate]);
175
176 useEffect(() => {
177 const onKeyDown = (event: KeyboardEvent) => {
178 if (event.defaultPrevented) return;
179 if (event.key !== "Escape") return;
180 event.preventDefault();
181 const activeElement = document.activeElement;
182 if (activeElement instanceof HTMLElement) {
183 activeElement.blur();
184 }
185 navigateBackWithinApp();
186 };
187 window.addEventListener("keydown", onKeyDown);
188 return () => window.removeEventListener("keydown", onKeyDown);
189 }, [navigateBackWithinApp]);
190
191 return (
192 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
193 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
194 <WorkspacePageHeader electron={isElectron}>
195 <ProjectSettingsBreadcrumb projectKey={projectKey} />
196 </WorkspacePageHeader>
197 <ProjectSettingsPanel projectKey={projectKey} />
198 </div>
199 </SidebarInset>
200 );
201}
202
203function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {
204 const groups = useSettingsProjectGroups();
205 const navigate = useNavigate();
206 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
207 const openProjectMenu = (event: ReactMouseEvent<HTMLButtonElement>) => {
208 const api = readLocalApi();
209 if (!api) return;
210
211 const rect = event.currentTarget.getBoundingClientRect();
212 const items: ContextMenuItem<string>[] = groups.map((group) => ({
213 id: group.projectKey,
214 label: group.displayName,
215 }));
216 void settlePromise(() =>
217 api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }),
218 ).then((clicked) => {
219 if (clicked._tag === "Failure" || clicked.value === null) return;
220 void navigate({
221 to: "/projects/$projectKey",
222 params: { projectKey: clicked.value },
223 replace: true,
224 hashScrollIntoView: false,
225 });
226 });
227 };
228
229 return (
230 <WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb">
231 <WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem>
232 <WorkspaceBreadcrumbSeparator />
233 <WorkspaceBreadcrumbItem current>
234 {selected ? (
235 <button
236 type="button"
237 aria-haspopup="menu"
238 aria-label="Switch project"
239 onClick={openProjectMenu}
240 className="group/project-title inline-flex min-w-0 max-w-64 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
241 >
242 <span className="min-w-0 truncate">{selected.displayName}</span>
243 <ChevronDownIcon
244 aria-hidden
245 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/project-title:opacity-100 group-focus-visible/project-title:opacity-100"
246 />
247 </button>
248 ) : (
249 <span className="truncate text-muted-foreground">Unavailable project</span>
250 )}
251 </WorkspaceBreadcrumbItem>
252 </WorkspaceBreadcrumb>
253 );
254}
255
256function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
257 const groups = useSettingsProjectGroups();165 const groups = useSettingsProjectGroups();
258 const navigate = useNavigate();166 const navigate = useNavigate();
259167
260 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;168 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
169 const members = useMemo(
170 () =>
171 selected?.memberProjects.filter(
172 (member) => environmentId === null || member.environmentId === environmentId,
173 ) ?? [],
174 [selected, environmentId],
175 );
261176
262 // Remember the members of the last rendered group so a grouping-rule change177 // Remember the members of the last rendered group so a grouping-rule change
263 // (which changes the group key) can follow the project to its new group.178 // (which changes the group key) can follow the project to its new group.
264 const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null);179 const lastSelectionRef = useRef<{
180 key: string;
181 environmentId: EnvironmentId | null;
182 memberKeys: string[];
183 } | null>(null);
265 useEffect(() => {184 useEffect(() => {
266 if (!selected) return;185 if (!selected || members.length === 0) return;
267 lastSelectionRef.current = {186 lastSelectionRef.current = {
268 key: selected.projectKey,187 key: selected.projectKey,
269 memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey),188 environmentId,
189 memberKeys: members.map((member) => member.physicalProjectKey),
270 };190 };
271 }, [selected]);191 }, [selected, members, environmentId]);
272192
273 // A grouping-rule change replaces the group key mid-visit; follow the193 // A grouping-rule change replaces the group key mid-visit; follow the
274 // project to its new key instead of parking on the not-found state.194 // project to its new key instead of parking on the not-found state.
275 useEffect(() => {195 useEffect(() => {
276 if (selected !== null) return;196 if (members.length > 0) return;
277 const last = lastSelectionRef.current;197 const last = lastSelectionRef.current;
278 if (last?.key !== projectKey) return;198 if (last?.key !== projectKey || last.environmentId !== environmentId) return;
279 const successor = groups.find((group) =>199 const successor = groups.find((group) =>
280 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),200 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
281 );201 );
282 if (successor) {202 if (successor) {
283 void navigate({203 void navigate({
284 to: "/projects/$projectKey",204 to: "/settings/projects",
285 params: { projectKey: successor.projectKey },205 search: { project: successor.projectKey, machine: environmentId ?? undefined },
286 replace: true,206 replace: true,
287 hashScrollIntoView: false,207 hashScrollIntoView: false,
288 });208 });
289 }209 }
290 }, [groups, navigate, projectKey, selected]);210 }, [groups, navigate, projectKey, members.length, environmentId]);
291211
292 if (!selected) {212 if (!selected) {
293 return (213 return (
...@@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {...@@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
298 </div>218 </div>
299 );219 );
300 }220 }
301 return <ProjectDetail key={selected.projectKey} group={selected} />;221 if (members.length === 0)
222 return (
223 <p className="p-8 text-sm text-muted-foreground">
224 This project has no checkout on this machine.
225 </p>
226 );
227 const scopedGroup = {
228 ...selected,
229 memberProjects: members,
230 environmentId: members[0]!.environmentId,
231 id: members[0]!.id,
232 };
233 return (
234 <ProjectDetail
235 key={`${selected.projectKey}:${environmentId ?? "all"}`}
236 group={scopedGroup}
237 hasOtherMembers={members.length < selected.memberProjects.length}
238 />
239 );
240}
241
242function reportScriptFailure(result: AtomCommandResult<unknown, unknown>) {
243 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
244 const error = squashAtomCommandFailure(result);
245 toastManager.add({
246 type: "error",
247 title: "Failed to save project actions",
248 description: error instanceof Error ? error.message : "An error occurred.",
249 });
250 }
251 return mapAtomCommandResult(result, () => undefined);
252}
253
254export function useProjectScriptSettings(
255 targets: readonly {
256 environmentId: EnvironmentId;
257 settings: ServerSettings;
258 keybindings: ResolvedKeybindingsConfig;
259 project?: { id: ProjectId; scripts: readonly ProjectScript[] };
260 }[],
261) {
262 const projects = useProjects();
263 const [saving, setSaving] = useState(false);
264 const savingRef = useRef(false);
265 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update");
266 const upsertKeybinding = useAtomCommand(
267 serverEnvironment.upsertKeybinding,
268 "action shortcut update",
269 );
270 const removeKeybinding = useAtomCommand(
271 serverEnvironment.removeKeybinding,
272 "action shortcut removal",
273 );
274
275 async function persist(
276 transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null,
277 scriptId?: string,
278 keybinding?: string | null,
279 ): Promise<AtomCommandResult<void, unknown>> {
280 if (savingRef.current || targets.length === 0) {
281 const message = "No available machine, or another action change is saving.";
282 toastManager.add({ type: "error", title: "Actions not saved", description: message });
283 return AsyncResult.failure(Cause.fail(new Error(message)));
284 }
285 savingRef.current = true;
286 setSaving(true);
287 try {
288 for (const { environmentId, settings, keybindings, project } of targets) {
289 const current = project
290 ? resolveProjectScripts(settings, project)
291 : settings.defaultProjectScripts;
292 const nextScripts = transform(current);
293 const effectiveScripts = nextScripts ?? settings.defaultProjectScripts;
294 const result = await updateSettings({
295 environmentId,
296 input: {
297 patch: project
298 ? { projectScriptOverrides: { [project.id]: nextScripts } }
299 : { defaultProjectScripts: nextScripts ?? [] },
300 },
301 });
302 if (result._tag === "Failure") return reportScriptFailure(result);
303 if (!isElectron) continue;
304 const changedIds = scriptId
305 ? [scriptId]
306 : current
307 .filter((script) => !effectiveScripts.some((next) => next.id === script.id))
308 .map((script) => script.id);
309 for (const id of changedIds) {
310 const command = commandForProjectScript(id);
311 const previousValue = keybindingValueForCommand(keybindings, command);
312 const previous = previousValue
313 ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command })
314 : null;
315 const next = decodeProjectScriptKeybindingRule({ keybinding, command });
316 const retainedElsewhere =
317 !nextScripts?.some((script) => script.id === id) &&
318 ((project && settings.defaultProjectScripts.some((script) => script.id === id)) ||
319 Object.entries(settings.projectScriptOverrides).some(
320 ([projectId, scripts]) =>
321 projectId !== project?.id && scripts?.some((script) => script.id === id),
322 ) ||
323 projects.some(
324 (other) =>
325 other.environmentId === environmentId &&
326 other.id !== project?.id &&
327 (project ? resolveProjectScripts(settings, other) : other.scripts).some(
328 (script) => script.id === id,
329 ),
330 ));
331 const bindingResult = next
332 ? await upsertKeybinding({
333 environmentId,
334 input:
335 previous && previous.key !== next.key ? { ...next, replace: previous } : next,
336 })
337 : previous && !retainedElsewhere
338 ? await removeKeybinding({ environmentId, input: previous })
339 : null;
340 if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult);
341 }
342 }
343 return AsyncResult.success(undefined);
344 } finally {
345 savingRef.current = false;
346 setSaving(false);
347 }
348 }
349
350 function submit(scriptId: string | null, input: NewProjectScriptInput) {
351 const existingIds = [
352 ...projects.flatMap((project) => project.scripts.map((script) => script.id)),
353 ...targets.flatMap(({ settings, project }) =>
354 [
355 ...settings.defaultProjectScripts,
356 ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []),
357 ...(project?.scripts ?? []),
358 ].map((script) => script.id),
359 ),
360 ];
361 const id = scriptId ?? nextProjectScriptId(input.name, existingIds);
362 const next = buildProjectScript(id, input);
363 return persist(
364 (current) => {
365 const updated = current.map((script) =>
366 script.id === id
367 ? next
368 : input.runOnWorktreeCreate
369 ? { ...script, runOnWorktreeCreate: false }
370 : script,
371 );
372 return scriptId === null ? [...updated, next] : updated;
373 },
374 id,
375 input.keybinding,
376 );
377 }
378
379 return { saving, persist, submit };
302}380}
303381
304function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {382function ProjectDetail({
383 group,
384 hasOtherMembers,
385}: {
386 group: SidebarProjectSnapshot;
387 hasOtherMembers: boolean;
388}) {
305 const navigate = useNavigate();389 const navigate = useNavigate();
306 const primaryEnvironmentId = usePrimaryEnvironmentId();390 const primaryEnvironmentId = usePrimaryEnvironmentId();
391 const { environments } = useEnvironments();
392 const environmentById = useMemo(
393 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
394 [environments],
395 );
307 const representative =396 const representative =
308 group.memberProjects.find(397 group.memberProjects.find(
309 (member) => member.environmentId === group.environmentId && member.id === group.id,398 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
310 ) ?? group.memberProjects[0]!;399 ) ?? group.memberProjects[0]!;
311 const settings = usePrimarySettings();
312 // Provider instances and model options belong to the environment that runs400 // Provider instances and model options belong to the environment that runs
313 // the project's threads. The hosted app has no primary environment, so401 // the project's threads. The hosted app has no primary environment, so
314 // reading them from there would show "No providers available" everywhere.402 // reading them from there would show "No providers available" everywhere.
...@@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
320 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);408 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
321 const threads = useThreadShells();409 const threads = useThreadShells();
322 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });410 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
323 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });411 const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting");
324 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {412 const [savingBrowserAccess, setSavingBrowserAccess] = useState(false);
325 reportFailure: false,413 const savingBrowserAccessRef = useRef(false);
326 });414 const browserOverrides = group.memberProjects.map(
327 const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {415 (member) =>
328 reportFailure: false,416 environmentById.get(member.environmentId)?.serverConfig?.settings
417 .projectAgentBrowserAccessOverrides[member.id],
418 );
419 const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id];
420 const browserMixed = group.memberProjects.some((member, index) => {
421 const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
422 if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false;
423 return (
424 browserOverrides[index] !== browserOverride ||
425 (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !==
426 (browserOverride ?? projectSettings.enableAgentBrowserAccess)
427 );
329 });428 });
429 const setBooleanOverride = async (
430 key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides",
431 enabled: boolean | undefined,
432 ) => {
433 if (savingBrowserAccessRef.current) return;
434 savingBrowserAccessRef.current = true;
435 setSavingBrowserAccess(true);
436 try {
437 const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId));
438 for (const environmentId of environmentIds) {
439 const environment = environmentById.get(environmentId);
440 if (!environment?.serverConfig || environment.connection.phase !== "connected") {
441 toastManager.add({
442 type: "warning",
443 title: "Setting not saved",
444 description: `Connect ${environment?.label ?? "this machine"} and try again.`,
445 });
446 return;
447 }
448 }
449 if (key === "projectAutoPullOverrides" && enabled === undefined) {
450 const result = await updateAllMembers(
451 { autoPull: false },
452 "Failed to reset automatic pull",
453 );
454 if (result._tag === "Failure") return;
455 }
456 for (const environmentId of environmentIds) {
457 const overrides = Object.fromEntries(
458 group.memberProjects
459 .filter((member) => member.environmentId === environmentId)
460 .map((member) => [member.id, enabled ?? null]),
461 );
462 const result = await updateServerSettings({
463 environmentId,
464 input: { patch: { [key]: overrides } },
465 });
466 if (result._tag === "Failure") {
467 reportFailure(
468 `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`,
469 mapAtomCommandResult(result, () => undefined),
470 );
471 return;
472 }
473 }
474 } finally {
475 savingBrowserAccessRef.current = false;
476 setSavingBrowserAccess(false);
477 }
478 };
479 const setBrowserAccess = (enabled: boolean | undefined) =>
480 setBooleanOverride("projectAgentBrowserAccessOverrides", enabled);
481 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
330 const projectNameEditedRef = useRef(false);482 const projectNameEditedRef = useRef(false);
331 const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
332 onCopy: ({ path }) => {
333 toastManager.add({ type: "success", title: "Path copied", description: path });
334 },
335 onError: (error) => {
336 toastManager.add(
337 stackedThreadToast({
338 type: "error",
339 title: "Failed to copy path",
340 description: error instanceof Error ? error.message : "An error occurred.",
341 }),
342 );
343 },
344 });
345483
346 const faviconPath = representative.faviconPath ?? null;484 const faviconPath = representative.faviconPath ?? null;
347 const projectIcon = representative.projectIcon ?? null;485 const projectIcon = representative.projectIcon ?? null;
...@@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
355 ? window.desktopBridge?.pickProjectFavicon493 ? window.desktopBridge?.pickProjectFavicon
356 : undefined;494 : undefined;
357495
358 const threadCountByMember = useMemo(() => {
359 const counts = new Map<string, number>();
360 for (const thread of threads) {
361 const key = `${thread.environmentId}:${thread.projectId}`;
362 counts.set(key, (counts.get(key) ?? 0) + 1);
363 }
364 return counts;
365 }, [threads]);
366 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {496 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
367 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;497 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
368 const error = squashAtomCommandFailure(result);498 const error = squashAtomCommandFailure(result);
...@@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
437567
438 // ----- default model -----568 // ----- default model -----
439 const storedSelection = representative.defaultModelSelection;569 const storedSelection = representative.defaultModelSelection;
440 const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection);570 const resolvedSelection = resolveDefaultProviderModelSelection(
571 serverProviders,
572 storedSelection ?? projectSettings.defaultModelSelection,
573 );
574 const mixedModel = group.memberProjects.some((member) => {
575 const config = environmentById.get(member.environmentId)?.serverConfig;
576 return (
577 !Equal.equals(member.defaultModelSelection, storedSelection) ||
578 (config !== null &&
579 config !== undefined &&
580 environmentById.get(representative.environmentId)?.serverConfig != null &&
581 JSON.stringify(
582 resolveDefaultProviderModelSelection(
583 config.providers,
584 member.defaultModelSelection ?? config.settings.defaultModelSelection,
585 ),
586 ) !== JSON.stringify(resolvedSelection))
587 );
588 });
441 const resolvedInstanceId = resolvedSelection?.instanceId ?? null;589 const resolvedInstanceId = resolvedSelection?.instanceId ?? null;
442 const resolvedModel = resolvedSelection?.model ?? null;590 const resolvedModel = resolvedSelection?.model ?? null;
443 const instanceEntries = useMemo(591 const instanceEntries = useMemo(
...@@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
461 [resolvedInstanceId, resolvedModel, serverProviders, projectSettings],609 [resolvedInstanceId, resolvedModel, serverProviders, projectSettings],
462 );610 );
463 const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);611 const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);
464 const setDefaultModel = useCallback(612 const setDefaultModel = (selection: ModelSelection | null) => {
465 (selection: ModelSelection | null) =>613 if (selection !== null) {
466 void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"),614 for (const member of group.memberProjects) {
467 [updateAllMembers],615 const environment = environmentById.get(member.environmentId);
468 );616 const config = environment?.serverConfig;
617 const entry = config
618 ? applyProviderInstanceSettings(
619 deriveProviderInstanceEntries(config.providers),
620 config.settings,
621 ).find((candidate) => candidate.instanceId === selection.instanceId)
622 : undefined;
623 const options = config
624 ? getCustomModelOptionsByInstance(
625 { ...projectSettings, ...config.settings },
626 config.providers,
627 ).get(selection.instanceId)
628 : undefined;
629 if (
630 !entry?.enabled ||
631 !entry.isAvailable ||
632 !options?.some((model) => model.slug === selection.model && !model.isUnavailable)
633 ) {
634 toastManager.add({
635 type: "warning",
636 title: "Project model not saved",
637 description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`,
638 });
639 return;
640 }
641 }
642 }
643 void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model");
644 };
469645
470 // ----- new-thread workspace mode -----646 // ----- new-thread workspace mode -----
471 const storedEnvMode = representative.defaultThreadEnvMode ?? null;647 const storedEnvMode = representative.defaultThreadEnvMode ?? null;
648 const mixedWorkspace = group.memberProjects.some(
649 (member) => member.defaultThreadEnvMode !== storedEnvMode,
650 );
472 const setDefaultThreadEnvMode = useCallback(651 const setDefaultThreadEnvMode = useCallback(
473 (mode: ThreadEnvMode | null) =>652 (mode: ThreadEnvMode | null) =>
474 void updateAllMembers(653 void updateAllMembers(
...@@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
478 [updateAllMembers],657 [updateAllMembers],
479 );658 );
480659
481 const autoPull = representative.autoPull ?? false;660 const autoPull = resolveProjectAutoPull(
482 const setAutoPull = useCallback(661 projectSettings,
483 (enabled: boolean) =>662 representative.id,
484 void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"),663 representative.autoPull,
485 [updateAllMembers],
486 );664 );
665 const autoPullOverridden = group.memberProjects.some(
666 (member) =>
667 member.autoPull ||
668 environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[
669 member.id
670 ] !== undefined,
671 );
672 const mixedAutoPull = group.memberProjects.some((member) => {
673 const settings = environmentById.get(member.environmentId)?.serverConfig?.settings;
674 return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull;
675 });
676 const setAutoPull = (enabled: boolean | undefined) =>
677 setBooleanOverride("projectAutoPullOverrides", enabled);
487678
488 // ----- project icon -----679 // ----- project icon -----
489 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);680 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
...@@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
506 );697 );
507698
508 // ----- checkout selection and scripts -----699 // ----- checkout selection and scripts -----
509 const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey);700 const hasMultipleCheckouts = group.memberProjects.length > 1;
510 const selectedCheckout =701 const [selectedCheckoutKey, setSelectedCheckoutKey] = useState<string | null>(null);
511 group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ??702 const selectedCheckoutMatch = group.memberProjects.find(
512 representative;703 (member) => member.physicalProjectKey === selectedCheckoutKey,
704 );
705 const selectedCheckout = selectedCheckoutMatch ?? representative;
513 const selectedServerConfig = useAtomValue(706 const selectedServerConfig = useAtomValue(
514 serverEnvironment.configValueAtom(selectedCheckout.environmentId),707 serverEnvironment.configValueAtom(selectedCheckout.environmentId),
515 );708 );
516 const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;709 const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
517 const scripts = selectedCheckout.scripts;710 const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId);
711 const scripts = resolveProjectScripts(scriptSettings, selectedCheckout);
712 const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout);
518 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);713 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
519 // Script writes replace the whole array, so two overlapping writes computed714 const {
520 // from the same snapshot would drop each other's changes. One at a time.715 saving: isSavingScripts,
521 const [isSavingScripts, setIsSavingScripts] = useState(false);716 persist: persistScripts,
522 const savingScriptsRef = useRef(false);717 submit: submitScript,
718 } = useProjectScriptSettings([
719 {
720 environmentId: selectedCheckout.environmentId,
721 settings: scriptSettings,
722 keybindings,
723 project: selectedCheckout,
724 },
725 ]);
523 const t3File = useT3ProjectFileState(726 const t3File = useT3ProjectFileState(
524 selectedCheckout.environmentId,727 selectedCheckout.environmentId,
525 selectedCheckout.workspaceRoot,728 selectedCheckout.workspaceRoot,
526 );729 );
527 // What the "Default" option resolves to while no override is set: the730 // What the "Default" option resolves to while no override is set: the
528 // repo's t3.json value when present, otherwise the global setting.731 // repo's t3.json value when present, otherwise the global setting.
529 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode;732 const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode;
530 const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";733 const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";
531 const importableScripts = useMemo(734 const importableScripts = useMemo(
532 () =>735 () =>
...@@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
541 [scripts, t3File.scripts],744 [scripts, t3File.scripts],
542 );745 );
543746
544 const persistScripts = useCallback(747 const deleteScript = (scriptId: string) =>
545 async (748 void persistScripts(
546 nextScripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>,749 (current) => current.filter((script) => script.id !== scriptId),
547 keybinding: string | null | undefined,750 scriptId,
548 keybindingCommand: ReturnType<typeof commandForProjectScript>,751 null,
549 ): Promise<AtomCommandResult<void, unknown>> => {752 );
550 if (savingScriptsRef.current) {
551 return AsyncResult.failure(
552 Cause.fail(new Error("Another script change is still saving. Try again.")),
553 );
554 }
555 savingScriptsRef.current = true;
556 setIsSavingScripts(true);
557 try {
558 // Captured before the write so a cleared or deleted binding can be
559 // removed from the keybindings config afterwards.
560 const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand);
561 const updateResult = mapAtomCommandResult(
562 await updateProject({
563 environmentId: selectedCheckout.environmentId,
564 input: { projectId: selectedCheckout.id, scripts: nextScripts },
565 }),
566 () => undefined,
567 );
568 if (updateResult._tag === "Failure") {
569 reportFailure("Failed to save scripts", updateResult);
570 return updateResult;
571 }
572
573 const keybindingRule = decodeProjectScriptKeybindingRule({
574 keybinding,
575 command: keybindingCommand,
576 });
577 if (!isElectron) return updateResult;
578 const environmentIds = [selectedCheckout.environmentId];
579 const previousTarget = previousKeybinding
580 ? decodeProjectScriptKeybindingRule({
581 keybinding: previousKeybinding,
582 command: keybindingCommand,
583 })
584 : null;
585 if (keybindingRule) {
586 // `replace` swaps the command's previous rule instead of appending a
587 // second one that would keep the old shortcut alive.
588 const input =
589 previousTarget && previousTarget.key !== keybindingRule.key
590 ? { ...keybindingRule, replace: previousTarget }
591 : keybindingRule;
592 for (const environmentId of environmentIds) {
593 const result = mapAtomCommandResult(
594 await upsertKeybinding({ environmentId, input }),
595 () => undefined,
596 );
597 if (result._tag === "Failure") {
598 reportFailure("Failed to save keybinding", result);
599 return result;
600 }
601 }
602 } else if (previousTarget) {
603 for (const environmentId of environmentIds) {
604 const result = mapAtomCommandResult(
605 await removeKeybinding({ environmentId, input: previousTarget }),
606 () => undefined,
607 );
608 if (result._tag === "Failure") {
609 reportFailure("Failed to remove keybinding", result);
610 return result;
611 }
612 }
613 }
614 return updateResult;
615 } finally {
616 savingScriptsRef.current = false;
617 setIsSavingScripts(false);
618 }
619 },
620 [
621 keybindings,
622 removeKeybinding,
623 reportFailure,
624 selectedCheckout.environmentId,
625 selectedCheckout.id,
626 updateProject,
627 upsertKeybinding,
628 ],
629 );
630
631 const submitScript = useCallback(
632 async (
633 scriptId: string | null,
634 input: NewProjectScriptInput,
635 ): Promise<AtomCommandResult<void, unknown>> => {
636 if (scriptId === null) {
637 const nextId = nextProjectScriptId(
638 input.name,
639 scripts.map((script) => script.id),
640 );
641 const nextScript = buildProjectScript(nextId, input);
642 const nextScripts = input.runOnWorktreeCreate
643 ? [
644 ...scripts.map((script) =>
645 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
646 ),
647 nextScript,
648 ]
649 : [...scripts, nextScript];
650 return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId));
651 }
652
653 const updatedScript = buildProjectScript(scriptId, input);
654 const nextScripts = scripts.map((script) =>
655 script.id === scriptId
656 ? updatedScript
657 : input.runOnWorktreeCreate
658 ? { ...script, runOnWorktreeCreate: false }
659 : script,
660 );
661 return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId));
662 },
663 [persistScripts, scripts],
664 );
665
666 const deleteScript = useCallback(
667 (scriptId: string) => {
668 const nextScripts = scripts.filter((script) => script.id !== scriptId);
669 void persistScripts(nextScripts, null, commandForProjectScript(scriptId));
670 },
671 [persistScripts, scripts],
672 );
673753
674 const importFileScript = useCallback(754 const importFileScript = useCallback(
675 async (fileScript: T3ProjectFileScript) => {755 async (fileScript: T3ProjectFileScript) => {
...@@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
692 });772 });
693 }773 }
694 },774 },
695 [submitScript],775 [submitScript, setEditorRequest],
696 );776 );
697777
698 // ----- checkouts -----778 // ----- checkouts -----
...@@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
720 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),800 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
721 );801 );
722 const isWholeGroup = members.length === group.memberProjects.length;802 const isWholeGroup = members.length === group.memberProjects.length;
803 const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project";
723 const singleMember = members.length === 1 ? members[0]! : null;804 const singleMember = members.length === 1 ? members[0]! : null;
724 const targetLabel = singleMember?.title ?? group.displayName;805 const targetLabel = singleMember?.title ?? group.displayName;
725 const confirmed = await settlePromise(() =>806 const confirmed = await settlePromise(() =>
726 api.dialogs.confirm(807 api.dialogs.confirm(
727 [808 [
728 projectThreads.length > 0809 projectThreads.length > 0
729 ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`810 ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`
730 : `Remove project "${targetLabel}"?`,811 : `Remove ${targetKind} "${targetLabel}"?`,
731 ...(singleMember812 ...(singleMember
732 ? [813 ? [
733 `Path: ${singleMember.workspaceRoot}`,814 `Path: ${singleMember.workspaceRoot}`,
...@@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
741 "This permanently clears conversation history for those threads and any archived threads.",822 "This permanently clears conversation history for those threads and any archived threads.",
742 ]823 ]
743 : ["This permanently clears any archived conversation history."]),824 : ["This permanently clears any archived conversation history."]),
744 isWholeGroup825 isWholeGroup && !hasOtherMembers
745 ? "This removes only the project entries, not the files on disk."826 ? "This removes only the project entries, not the files on disk."
746 : "Other entries in this grouped project are unaffected.",827 : "Other entries in this grouped project are unaffected.",
747 "This action cannot be undone.",828 "This action cannot be undone.",
...@@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
783 draftStore.clearProjectDraftThreadId(projectRef);864 draftStore.clearProjectDraftThreadId(projectRef);
784 }865 }
785866
786 // The project's settings page just deleted itself; there is no projects
787 // listing to fall back to, so leave settings entirely.
788 if (isWholeGroup) {867 if (isWholeGroup) {
789 void navigate({ to: "/", replace: true });868 if (hasOtherMembers) {
869 void navigate({
870 to: "/settings/projects",
871 search: { project: group.projectKey, machine: undefined },
872 replace: true,
873 });
874 } else {
875 void navigate({ to: "/", replace: true });
876 }
790 }877 }
791 },878 },
792 [879 [
793 deleteProject,880 deleteProject,
794 group.displayName,881 group.displayName,
795 group.memberProjects.length,882 group.memberProjects.length,
883 group.projectKey,
884 hasOtherMembers,
796 navigate,885 navigate,
797 reportFailure,886 reportFailure,
798 threads,887 threads,
799 ],888 ],
800 );889 );
801890
802 const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0;
803 const selectedCheckoutGrouping =891 const selectedCheckoutGrouping =
804 projectGroupingSettings.sidebarProjectGroupingOverrides?.[892 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
805 deriveProjectGroupingOverrideKey(selectedCheckout)893 deriveProjectGroupingOverrideKey(selectedCheckout)
806 ] ?? "inherit";894 ] ?? "inherit";
807 const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine";895 const checkoutLabel = (member: SidebarProjectGroupMember) => {
896 const label = member.environmentLabel ?? "This machine";
897 return group.memberProjects.some(
898 (other) =>
899 other.physicalProjectKey !== member.physicalProjectKey &&
900 (other.environmentLabel ?? "This machine") === label,
901 )
902 ? `${label} · ${member.workspaceRoot}`
903 : label;
904 };
905 const selectedCheckoutLabel = checkoutLabel(selectedCheckout);
808906
809 return (907 return (
810 <>908 <>
811 <SettingsPageContainer width="wide" className="gap-8">909 <SettingsPageContainer className="gap-6">
812 <SettingsSection title="Project">910 <SettingsSection title="Project" hideTitle>
813 <SettingsRow911 <SettingsRow
814 title="Name"912 title="Name"
815 description="The shared name for this project group in the sidebar and thread lists."913 description="The shared name for this project group in the sidebar and thread lists."
...@@ -887,11 +985,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -887,11 +985,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
887 />985 />
888 <SettingsRow986 <SettingsRow
889 title="Model"987 title="Model"
890 description="New threads in this project start with this model. Applies to every checkout in this group."988 status={
989 mixedModel
990 ? "Mixed defaults or overrides. Choosing a model updates all selected checkouts."
991 : storedSelection === null
992 ? "Inherited"
993 : "Overridden"
994 }
995 description={
996 storedSelection === null
997 ? "Inherited from machine defaults. New threads use the default model."
998 : "Overridden for this project. Reset to use the default model."
999 }
891 resetAction={1000 resetAction={
892 storedSelection !== null ? (1001 group.memberProjects.some((member) => member.defaultModelSelection !== null) ? (
893 <SettingResetButton1002 <SettingResetButton
894 label="project default model"1003 label="project default model"
1004 tooltip="Reset to inherited model"
895 onClick={() => setDefaultModel(null)}1005 onClick={() => setDefaultModel(null)}
896 />1006 />
897 ) : null1007 ) : null
...@@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
946 />1056 />
947 <SettingsRow1057 <SettingsRow
948 title="Workspace"1058 title="Workspace"
949 description="Where new threads in this project start. Overrides t3.json and the global default; applies to every checkout in this group."1059 status={
1060 mixedWorkspace
1061 ? "Mixed overrides. Choosing a workspace updates all selected checkouts."
1062 : storedEnvMode === null
1063 ? "Inherited"
1064 : "Overridden"
1065 }
1066 description={
1067 storedEnvMode === null
1068 ? "Inherited from t3.json or machine defaults."
1069 : "Overridden for this project. Reset to inherit its workspace default."
1070 }
950 resetAction={1071 resetAction={
951 storedEnvMode !== null ? (1072 group.memberProjects.some((member) => member.defaultThreadEnvMode !== null) ? (
952 <SettingResetButton1073 <SettingResetButton
953 label="project workspace default"1074 label="project workspace default"
1075 tooltip="Reset to inherited workspace"
954 onClick={() => setDefaultThreadEnvMode(null)}1076 onClick={() => setDefaultThreadEnvMode(null)}
955 />1077 />
956 ) : null1078 ) : null
...@@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
990 <SettingsRow1112 <SettingsRow
991 title="Automatically pull"1113 title="Automatically pull"
992 description="Keeps the default branch current in the background when the checkout has no local changes or commits."1114 description="Keeps the default branch current in the background when the checkout has no local changes or commits."
1115 status={
1116 mixedAutoPull
1117 ? "Mixed"
1118 : autoPullOverridden
1119 ? "Overridden"
1120 : `Inherited (${autoPull ? "on" : "off"})`
1121 }
993 resetAction={1122 resetAction={
994 autoPull ? (1123 autoPullOverridden ? (
995 <SettingResetButton label="automatic pull" onClick={() => setAutoPull(false)} />1124 <SettingResetButton
1125 label="automatic pull"
1126 tooltip="Reset to inherited automatic pull setting"
1127 disabled={savingBrowserAccess}
1128 onClick={() => void setAutoPull(undefined)}
1129 />
996 ) : null1130 ) : null
997 }1131 }
998 control={1132 control={
999 <Switch1133 <Switch
1000 checked={autoPull}1134 checked={autoPull}
1135 disabled={savingBrowserAccess}
1001 aria-label="Automatically pull the default branch"1136 aria-label="Automatically pull the default branch"
1002 onCheckedChange={setAutoPull}1137 onCheckedChange={(enabled) => void setAutoPull(enabled)}
1003 />1138 />
1004 }1139 }
1005 />1140 />
1141 <SettingsRow
1142 title="Agent browser access"
1143 description={
1144 browserMixed
1145 ? "Mixed defaults or overrides across selected checkouts."
1146 : browserOverride === undefined
1147 ? "Inherited from machine defaults. Controls agent access to the preview browser."
1148 : "Overridden for this project. Applies when the agent session next starts."
1149 }
1150 resetAction={
1151 browserOverrides.some((value) => value !== undefined) ? (
1152 <SettingResetButton
1153 label="project browser access"
1154 tooltip="Reset to inherited browser access"
1155 disabled={savingBrowserAccess}
1156 onClick={() => void setBrowserAccess(undefined)}
1157 />
1158 ) : null
1159 }
1160 control={
1161 <Select
1162 value={
1163 browserMixed
1164 ? "mixed"
1165 : browserOverride === undefined
1166 ? "inherit"
1167 : browserOverride
1168 ? "enabled"
1169 : "disabled"
1170 }
1171 disabled={savingBrowserAccess}
1172 onValueChange={(value) => {
1173 if (value === "inherit") void setBrowserAccess(undefined);
1174 else if (value === "enabled" || value === "disabled")
1175 void setBrowserAccess(value === "enabled");
1176 }}
1177 >
1178 <SelectTrigger size="sm" aria-label="Project agent browser access">
1179 <SelectValue>
1180 {browserMixed
1181 ? "Mixed"
1182 : browserOverride === undefined
1183 ? `Inherit (${projectSettings.enableAgentBrowserAccess ? "on" : "off"})`
1184 : browserOverride
1185 ? "On"
1186 : "Off"}
1187 </SelectValue>
1188 </SelectTrigger>
1189 <SelectPopup align="end" alignItemWithTrigger={false}>
1190 <SelectItem value="inherit">Inherit defaults</SelectItem>
1191 <SelectItem value="enabled">On</SelectItem>
1192 <SelectItem value="disabled">Off</SelectItem>
1193 </SelectPopup>
1194 </Select>
1195 }
1196 />
1006 </SettingsSection>1197 </SettingsSection>
10071198
1008 <SettingsSection1199 <SettingsSection title="Checkout">
1009 title="Checkout"1200 {hasMultipleCheckouts ? (
1010 headerAction={1201 <SettingsRow
1011 <Select1202 title="Checkout"
1012 value={selectedCheckout.physicalProjectKey}1203 description="Actions and grouping belong to this checkout."
1013 onValueChange={(value) => setSelectedCheckoutKey(String(value))}1204 control={
1014 >1205 <Select
1015 <SelectTrigger size="sm" className="max-w-64" aria-label="Selected checkout">1206 value={selectedCheckout.physicalProjectKey}
1016 <SelectValue>{selectedCheckoutLabel}</SelectValue>1207 onValueChange={(value) => {
1017 </SelectTrigger>1208 if (value) setSelectedCheckoutKey(value);
1018 <SelectPopup align="end" alignItemWithTrigger={false}>1209 }}
1019 {group.memberProjects.map((member) => (1210 >
1020 <SelectItem1211 <SelectTrigger size="sm" aria-label="Checkout">
1021 key={member.physicalProjectKey}1212 <SelectValue className="max-w-96 truncate">{selectedCheckoutLabel}</SelectValue>
1022 hideIndicator1213 </SelectTrigger>
1023 value={member.physicalProjectKey}1214 <SelectPopup align="end" alignItemWithTrigger={false}>
1024 >1215 {group.memberProjects.map((member) => (
1025 {member.environmentLabel ?? "This machine"} · {member.workspaceRoot}1216 <SelectItem key={member.physicalProjectKey} value={member.physicalProjectKey}>
1026 </SelectItem>1217 <span className="max-w-96 whitespace-normal break-all">
1027 ))}1218 {checkoutLabel(member)}
1028 </SelectPopup>1219 </span>
1029 </Select>1220 </SelectItem>
1030 }1221 ))}
1031 >1222 </SelectPopup>
1032 <div className="px-3 py-2 sm:px-4">1223 </Select>
1033 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">1224 }
1034 <Tooltip>1225 />
1035 <TooltipTrigger1226 ) : null}
1036 render={
1037 <button
1038 aria-label="Copy checkout path"
1039 className="group flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left outline-none hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
1040 type="button"
1041 onClick={() =>
1042 copyPathToClipboard(selectedCheckout.workspaceRoot, {
1043 path: selectedCheckout.workspaceRoot,
1044 })
1045 }
1046 >
1047 <code className="min-w-0 flex-1 truncate font-mono">
1048 {selectedCheckout.workspaceRoot}
1049 </code>
1050 <CopyIcon className="size-4 shrink-0 opacity-60 group-hover:opacity-100" />
1051 </button>
1052 }
1053 />
1054 <TooltipPopup side="top">Copy path</TooltipPopup>
1055 </Tooltip>
1056 <div className="shrink-0 border-l border-border/60 px-2 tabular-nums">
1057 {selectedCheckoutThreadCount === 1
1058 ? "1 thread"
1059 : `${selectedCheckoutThreadCount} threads`}
1060 </div>
1061 </div>
1062 </div>
1063 <SettingsRow1227 <SettingsRow
1064 title="Project grouping"1228 title="Project grouping"
1065 description="How this checkout joins project groups in the sidebar. Changing it can move you to a different project group."1229 description="How this checkout joins project groups in the sidebar. Changing it can move you to a different project group."
1230 resetAction={
1231 selectedCheckoutGrouping !== "inherit" ? (
1232 <SettingResetButton
1233 label="project grouping"
1234 tooltip="Reset to inherited project grouping"
1235 onClick={() => updateGroupingPreference(selectedCheckout, "inherit")}
1236 />
1237 ) : null
1238 }
1066 control={1239 control={
1067 <Select1240 <Select
1068 value={selectedCheckoutGrouping}1241 value={selectedCheckoutGrouping}
...@@ -1121,10 +1294,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -1121,10 +1294,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
1121 <div className="min-w-0">1294 <div className="min-w-0">
1122 <h3 className="text-base font-semibold text-foreground">Actions</h3>1295 <h3 className="text-base font-semibold text-foreground">Actions</h3>
1123 <p className="text-pretty text-sm text-muted-foreground">1296 <p className="text-pretty text-sm text-muted-foreground">
1124 Saved and run only in {selectedCheckoutLabel}.1297 {scriptsInherited
1298 ? "Inherited from machine defaults."
1299 : `Overridden for ${selectedCheckoutLabel}.`}
1125 </p>1300 </p>
1126 </div>1301 </div>
1127 <div className="flex w-full flex-wrap gap-1.5 sm:w-auto sm:shrink-0 sm:justify-end">1302 <div className="flex w-full flex-wrap gap-1.5 sm:w-auto sm:shrink-0 sm:justify-end">
1303 {!scriptsInherited ? (
1304 <SettingResetButton
1305 label="project actions"
1306 tooltip="Reset to inherited actions"
1307 disabled={isSavingScripts}
1308 onClick={() => void persistScripts(() => null)}
1309 />
1310 ) : null}
1128 {importableScripts.length > 0 ? (1311 {importableScripts.length > 0 ? (
1129 <Menu>1312 <Menu>
1130 <MenuTrigger1313 <MenuTrigger
...@@ -1173,65 +1356,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -1173,65 +1356,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
1173 </Button>1356 </Button>
1174 </div>1357 </div>
1175 </div>1358 </div>
1176 {scripts.length === 0 ? (1359 <ProjectActionsList
1177 <p className="px-3 py-2 text-base text-muted-foreground sm:px-4 sm:text-sm">1360 scripts={scripts}
1178 No actions configured for this checkout.1361 keybindings={keybindings}
1179 </p>1362 disabled={isSavingScripts}
1180 ) : (1363 onEdit={(script) => setEditorRequest(editorRequestForScript(script, keybindings))}
1181 scripts.map((script) => {1364 />
1182 const shortcutLabel = shortcutLabelForCommand(
1183 keybindings,
1184 commandForProjectScript(script.id),
1185 );
1186 return (
1187 <SettingsRow
1188 key={script.id}
1189 className="group py-2"
1190 title={
1191 <span className="flex min-w-0 items-center gap-2">
1192 <ScriptIcon
1193 icon={script.icon}
1194 className="size-4 shrink-0 text-muted-foreground"
1195 />
1196 <span className="min-w-0 truncate">{script.name}</span>
1197 {script.runOnWorktreeCreate ? (
1198 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground">
1199 setup
1200 </span>
1201 ) : null}
1202 {script.previewUrl ? (
1203 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground max-sm:hidden">
1204 preview · desktop only
1205 </span>
1206 ) : null}
1207 </span>
1208 }
1209 description={
1210 <code className="block max-w-full truncate font-mono">{script.command}</code>
1211 }
1212 control={
1213 <>
1214 {shortcutLabel ? (
1215 <span className="text-xs text-muted-foreground">{shortcutLabel}</span>
1216 ) : null}
1217 <Button
1218 size="icon-xs"
1219 variant="ghost"
1220 className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"
1221 aria-label={`Edit ${script.name}`}
1222 disabled={isSavingScripts}
1223 onClick={() =>
1224 setEditorRequest(editorRequestForScript(script, keybindings))
1225 }
1226 >
1227 <SettingsIcon className="size-3.5" />
1228 </Button>
1229 </>
1230 }
1231 />
1232 );
1233 })
1234 )}
1235 {t3File.status === "invalid" ? (1365 {t3File.status === "invalid" ? (
1236 <SettingsRow1366 <SettingsRow
1237 title="t3.json is invalid"1367 title="t3.json is invalid"
...@@ -1244,12 +1374,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -1244,12 +1374,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
1244 <SettingsSection title="Danger">1374 <SettingsSection title="Danger">
1245 <SettingsRow1375 <SettingsRow
1246 title={1376 title={
1247 group.memberProjects.length > 1 ? "Remove this project everywhere" : "Remove project"1377 hasOtherMembers
1378 ? "Remove checkout"
1379 : group.memberProjects.length > 1
1380 ? "Remove this project everywhere"
1381 : "Remove project"
1248 }1382 }
1249 description={1383 description={
1250 group.memberProjects.length > 11384 hasOtherMembers
1251 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`1385 ? "Deletes the selected machine's checkout entries and their threads. Other machines and files on disk are not touched."
1252 : "Deletes the project entry and its threads. Files on disk are not touched."1386 : group.memberProjects.length > 1
1387 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
1388 : "Deletes the project entry and its threads. Files on disk are not touched."
1253 }1389 }
1254 control={1390 control={
1255 <Button1391 <Button
...@@ -1258,7 +1394,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -1258,7 +1394,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
1258 onClick={() => void removeMembers(group.memberProjects)}1394 onClick={() => void removeMembers(group.memberProjects)}
1259 >1395 >
1260 <Trash2Icon />1396 <Trash2Icon />
1261 {group.memberProjects.length > 1 ? "Remove all entries" : "Remove project"}1397 {hasOtherMembers
1398 ? "Remove checkout"
1399 : group.memberProjects.length > 1
1400 ? "Remove all entries"
1401 : "Remove project"}
1262 </Button>1402 </Button>
1263 }1403 }
1264 />1404 />
apps/web/src/components/settings/ProjectsSettings.tsx created+160
...@@ -0,0 +1,160 @@
1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2import { FolderIcon } from "lucide-react";
3import type { ReactNode } from "react";
4import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
5import { ProjectFavicon } from "../ProjectFavicon";
6import { WorkspacePageContainer } from "../WorkspacePageContainer";
7import { useEnvironments } from "../../state/environments";
8import { Toggle, ToggleGroup } from "../ui/toggle-group";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel";
11import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
12
13function ScopePicker({
14 label,
15 value,
16 options,
17 onChange,
18}: {
19 label: "project" | "machine";
20 value: string | null;
21 options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>;
22 onChange: (value: string | null) => void;
23}) {
24 const selected = options.find((option) => option.value === value);
25 const allIcon =
26 label === "project" ? <FolderIcon aria-hidden className="size-3.5 shrink-0" /> : null;
27 return (
28 <Select
29 value={value ?? "all"}
30 onValueChange={(next) => {
31 if (next) onChange(next === "all" ? null : next);
32 }}
33 >
34 <SelectTrigger
35 size="compact"
36 aria-label={`${label === "project" ? "Project" : "Machine"} scope`}
37 className="w-auto min-w-0 max-w-52"
38 >
39 <SelectValue className="flex min-w-0 items-center gap-1.5">
40 {value === null ? allIcon : selected?.icon}
41 <span className="truncate">
42 {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)}
43 </span>
44 </SelectValue>
45 </SelectTrigger>
46 <SelectPopup align="start" alignItemWithTrigger={false}>
47 <SelectItem value="all">
48 <span className="flex items-center gap-2">
49 {allIcon}All {label}s
50 </span>
51 </SelectItem>
52 {options.map((option) => (
53 <SelectItem key={option.value} value={option.value}>
54 <span className="flex items-center gap-2">
55 {option.icon}
56 {option.label}
57 </span>
58 </SelectItem>
59 ))}
60 </SelectPopup>
61 </Select>
62 );
63}
64
65export function ProjectsSettings({
66 projectKey,
67 machineId,
68 onScopeChange,
69}: {
70 projectKey: string | null;
71 machineId: string | null;
72 onScopeChange: (project: string | null, machine: string | null) => void;
73}) {
74 const groups = useSettingsProjectGroups();
75 const { environments } = useEnvironments();
76 const machine = environments.find((environment) => environment.environmentId === machineId);
77 const machineOptions = environments.map((environment) => ({
78 value: environment.environmentId,
79 label: environment.label,
80 icon: (
81 <EnvironmentMachineIcon
82 aria-hidden
83 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
84 className="size-3.5 shrink-0"
85 />
86 ),
87 }));
88 return (
89 <div className="flex min-h-0 flex-1 flex-col">
90 <div className="scrollbar-gutter-both shrink-0 overflow-y-auto">
91 <WorkspacePageContainer className="pb-0">
92 <div
93 className="flex flex-wrap items-center gap-2"
94 role="group"
95 aria-label="Settings scope"
96 >
97 {environments.length > 3 ? (
98 <ScopePicker
99 label="machine"
100 value={machineId}
101 options={machineOptions}
102 onChange={(value) => onScopeChange(projectKey, value)}
103 />
104 ) : (
105 <ToggleGroup
106 aria-label="Machine scope"
107 variant="segmented"
108 className="max-w-full flex-wrap"
109 value={[machineId ?? "all"]}
110 onValueChange={(next) => {
111 const value = next[0];
112 if (value) onScopeChange(projectKey, value === "all" ? null : value);
113 }}
114 >
115 <Toggle value="all">All machines</Toggle>
116 {machineOptions.map((option) => (
117 <Toggle key={option.value} value={option.value} title={option.label}>
118 {option.icon}
119 <span className="max-w-36 truncate">{option.label}</span>
120 </Toggle>
121 ))}
122 </ToggleGroup>
123 )}
124 <div className="ms-auto">
125 <ScopePicker
126 label="project"
127 value={projectKey}
128 options={groups.map((group) => ({
129 value: group.projectKey,
130 label: group.displayName,
131 icon: (
132 <ProjectFavicon
133 environmentId={group.environmentId}
134 cwd={group.workspaceRoot}
135 projectName={group.title}
136 faviconPath={group.faviconPath}
137 projectIcon={group.projectIcon}
138 className="size-3.5"
139 />
140 ),
141 }))}
142 onChange={(value) => onScopeChange(value, machineId)}
143 />
144 </div>
145 </div>
146 </WorkspacePageContainer>
147 </div>
148 {machineId !== null && !machine ? (
149 <p className="p-8 text-sm text-muted-foreground">This machine is no longer available.</p>
150 ) : projectKey === null ? (
151 <ProjectDefaultsSettings environmentId={machine?.environmentId ?? null} />
152 ) : (
153 <ProjectSettingsPanel
154 projectKey={projectKey}
155 environmentId={machine?.environmentId ?? null}
156 />
157 )}
158 </div>
159 );
160}
apps/web/src/components/settings/SettingsPanels.tsx+10-41
...@@ -2557,55 +2557,24 @@ export function GeneralSettingsPanel() {...@@ -2557,55 +2557,24 @@ export function GeneralSettingsPanel() {
25572557
2558 <SettingsSection id="projects-and-threads" title="Projects & threads">2558 <SettingsSection id="projects-and-threads" title="Projects & threads">
2559 <SettingsRow2559 <SettingsRow
2560 serverScoped
2561 {...searchableSetting("new-threads")}2560 {...searchableSetting("new-threads")}
2562 description="Pick the default workspace mode for newly created draft threads."2561 description="Choose the default model and workspace for all projects or a specific project."
2563 resetAction={
2564 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ||
2565 settings.newWorktreesStartFromOrigin !==
2566 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? (
2567 <SettingResetButton
2568 label="new threads"
2569 onClick={() =>
2570 updateSettings({
2571 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
2572 newWorktreesStartFromOrigin:
2573 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
2574 })
2575 }
2576 />
2577 ) : null
2578 }
2579 control={2562 control={
2580 <Select2563 <Button
2581 value={settings.defaultThreadEnvMode}2564 render={
2582 onValueChange={(value) => {2565 <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} />
2583 if (value === "local" || value === "worktree") {2566 }
2584 updateSettings({ defaultThreadEnvMode: value });2567 size="sm"
2585 }2568 variant="outline"
2586 }}
2587 >2569 >
2588 <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default thread mode">2570 Project settings
2589 <SelectValue>2571 </Button>
2590 {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"}
2591 </SelectValue>
2592 </SelectTrigger>
2593 <SelectPopup align="end" alignItemWithTrigger={false}>
2594 <SelectItem hideIndicator value="local">
2595 Local
2596 </SelectItem>
2597 <SelectItem hideIndicator value="worktree">
2598 New worktree
2599 </SelectItem>
2600 </SelectPopup>
2601 </Select>
2602 }2572 }
2603 />2573 />
26042574
2605 <SettingsRow2575 <SettingsRow
2606 serverScoped2576 serverScoped
2607 className="bg-muted/20 sm:pl-9"2577 {...searchableSetting("start-from-origin")}
2608 title={searchableSetting("start-from-origin").title}
2609 description="Creates the worktree from the latest matching branch on origin instead of your local branch."2578 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
2610 resetAction={2579 resetAction={
2611 settings.newWorktreesStartFromOrigin !==2580 settings.newWorktreesStartFromOrigin !==
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-1
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 BlocksIcon,15 BlocksIcon,
16 BotIcon,16 BotIcon,
17 GitBranchIcon,17 GitBranchIcon,
18 PanelsTopLeftIcon,
18 KeyboardIcon,19 KeyboardIcon,
19 Link2Icon,20 Link2Icon,
20 PaletteIcon,21 PaletteIcon,
...@@ -72,6 +73,7 @@ const SETTINGS_SECTION_ICONS: Readonly<...@@ -72,6 +73,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
72> = {73> = {
73 "/settings/general": Settings2Icon,74 "/settings/general": Settings2Icon,
74 "/settings/appearance": PaletteIcon,75 "/settings/appearance": PaletteIcon,
76 "/settings/projects": PanelsTopLeftIcon,
75 "/settings/keybindings": KeyboardIcon,77 "/settings/keybindings": KeyboardIcon,
76 "/settings/providers": BotIcon,78 "/settings/providers": BotIcon,
77 "/settings/integrations": BlocksIcon,79 "/settings/integrations": BlocksIcon,
...@@ -274,12 +276,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -274,12 +276,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
274 setOpenMobile(false);276 setOpenMobile(false);
275 }277 }
276 const targetId = item.targetId ?? item.id;278 const targetId = item.targetId ?? item.id;
277 if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {279 if (
280 item.to !== "/settings/projects" &&
281 pathname === item.to &&
282 currentHash.replace(/^#/, "") === targetId
283 ) {
278 scrollToSettingsTarget(targetId);284 scrollToSettingsTarget(targetId);
279 return;285 return;
280 }286 }
281 void navigate({287 void navigate({
282 to: item.to,288 to: item.to,
289 search: (previous) =>
290 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
283 hash: targetId,291 hash: targetId,
284 replace: true,292 replace: true,
285 hashScrollIntoView: false,293 hashScrollIntoView: false,
apps/web/src/components/settings/settingsLayout.tsx+8-2
...@@ -284,7 +284,11 @@ export function SettingsRow({...@@ -284,7 +284,11 @@ export function SettingsRow({
284 ref={targetRef}284 ref={targetRef}
285 tabIndex={rowProps.id ? -1 : rowProps.tabIndex}285 tabIndex={rowProps.id ? -1 : rowProps.tabIndex}
286 data-slot="settings-row"286 data-slot="settings-row"
287 className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)}287 className={cn(
288 "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground",
289 children ? "pt-3 pb-1" : "py-3",
290 className,
291 )}
288 >292 >
289 <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8">293 <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8">
290 <div className="min-w-0 flex-1 space-y-1">294 <div className="min-w-0 flex-1 space-y-1">
...@@ -320,10 +324,12 @@ export function SettingsRow({...@@ -320,10 +324,12 @@ export function SettingsRow({
320324
321export function SettingResetButton({325export function SettingResetButton({
322 label,326 label,
327 tooltip = "Reset to default",
323 disabled = false,328 disabled = false,
324 onClick,329 onClick,
325}: {330}: {
326 label: string;331 label: string;
332 tooltip?: string;
327 disabled?: boolean;333 disabled?: boolean;
328 onClick: () => void;334 onClick: () => void;
329}) {335}) {
...@@ -345,7 +351,7 @@ export function SettingResetButton({...@@ -345,7 +351,7 @@ export function SettingResetButton({
345 </Button>351 </Button>
346 }352 }
347 />353 />
348 <TooltipPopup side="top">Reset to default</TooltipPopup>354 <TooltipPopup side="top">{tooltip}</TooltipPopup>
349 </Tooltip>355 </Tooltip>
350 );356 );
351}357}
apps/web/src/components/settings/settingsSearch.ts+12-3
...@@ -2,6 +2,7 @@ import { isElectron } from "~/env";...@@ -2,6 +2,7 @@ import { isElectron } from "~/env";
2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
33
4export type SettingsPath =4export type SettingsPath =
5 | "/settings/projects"
5 | "/settings/general"6 | "/settings/general"
6 | "/settings/appearance"7 | "/settings/appearance"
7 | "/settings/keybindings"8 | "/settings/keybindings"
...@@ -49,6 +50,7 @@ export interface SettingsSearchAvailability {...@@ -49,6 +50,7 @@ export interface SettingsSearchAvailability {
49export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {50export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
50 "/settings/general": "General",51 "/settings/general": "General",
51 "/settings/appearance": "Appearance",52 "/settings/appearance": "Appearance",
53 "/settings/projects": "Projects",
52 "/settings/keybindings": "Keybindings",54 "/settings/keybindings": "Keybindings",
53 "/settings/providers": "Providers",55 "/settings/providers": "Providers",
54 "/settings/integrations": "Integrations",56 "/settings/integrations": "Integrations",
...@@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
63 * that may not be mounted point at their nearest stable section instead.65 * that may not be mounted point at their nearest stable section instead.
64 */66 */
65export const SETTINGS_SEARCH_ITEMS = [67export const SETTINGS_SEARCH_ITEMS = [
68 {
69 id: "project-defaults",
70 title: "Project defaults and overrides",
71 to: "/settings/projects",
72 searchTerms: [
73 "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts",
74 ],
75 },
66 {76 {
67 id: "color-scheme",77 id: "color-scheme",
68 title: "Color scheme",78 title: "Color scheme",
...@@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [
235 {245 {
236 id: "new-threads",246 id: "new-threads",
237 title: "New threads",247 title: "New threads",
238 to: "/settings/general",248 to: "/settings/projects",
239 searchTerms: ["default workspace mode draft local worktree"],249 searchTerms: ["default workspace mode draft local worktree"],
240 },250 },
241 {251 {
242 id: "start-from-origin",252 id: "start-from-origin",
243 title: "Start from origin",253 title: "Start from origin",
244 to: "/settings/general",254 to: "/settings/general",
245 targetId: "new-threads",
246 searchTerms: ["new worktrees latest matching remote branch local"],255 searchTerms: ["new worktrees latest matching remote branch local"],
247 },256 },
248 {257 {
...@@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [
345 {354 {
346 id: "agent-browser-access",355 id: "agent-browser-access",
347 title: "Agent browser access",356 title: "Agent browser access",
348 to: "/settings/integrations",357 to: "/settings/projects",
349 searchTerms: ["allow open drive preview tools sessions"],358 searchTerms: ["allow open drive preview tools sessions"],
350 },359 },
351 {360 {
apps/web/src/hooks/useHandleNewThread.test.ts+23-3
...@@ -49,14 +49,31 @@ const testState = vi.hoisted(() => {...@@ -49,14 +49,31 @@ const testState = vi.hoisted(() => {
49});49});
5050
51vi.mock("@effect/atom-react", () => ({51vi.mock("@effect/atom-react", () => ({
52 useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }),52 useAtomValue: (atom: unknown) =>
53 atom === "primary-settings"
54 ? { newWorktreesStartFromOrigin: false }
55 : new Map([
56 [
57 "environment-ssh",
58 {
59 settings: {
60 defaultThreadEnvMode: "local",
61 newWorktreesStartFromOrigin: false,
62 defaultModelSelection: null,
63 },
64 },
65 ],
66 ]),
53}));67}));
54vi.mock("@t3tools/client-runtime/environment", () => ({68vi.mock("@t3tools/client-runtime/environment", () => ({
55 scopedProjectKey: () => "remote-project",69 scopedProjectKey: () => "remote-project",
56 scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),70 scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
57 scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),71 scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
58}));72}));
59vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" }));73vi.mock("@t3tools/contracts", () => ({
74 DEFAULT_RUNTIME_MODE: "default",
75 DEFAULT_SERVER_SETTINGS: {},
76}));
60vi.mock("@t3tools/shared/threadEnvMode", () => ({77vi.mock("@t3tools/shared/threadEnvMode", () => ({
61 resolveDefaultThreadEnvMode: (input: {78 resolveDefaultThreadEnvMode: (input: {
62 readonly projectFile: "local" | "worktree" | null;79 readonly projectFile: "local" | "worktree" | null;
...@@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({...@@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({
113 useProjects: () => [],130 useProjects: () => [],
114 useThread: () => null,131 useThread: () => null,
115}));132}));
116vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} }));133vi.mock("../state/server", () => ({
134 environmentServerConfigsAtom: {},
135 primaryServerSettingsAtom: "primary-settings",
136}));
117vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));137vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));
118vi.mock("../uiStateStore", () => ({138vi.mock("../uiStateStore", () => ({
119 legacyProjectCwdPreferenceKey: () => "remote-project",139 legacyProjectCwdPreferenceKey: () => "remote-project",
apps/web/src/hooks/useHandleNewThread.ts+20-10
...@@ -4,7 +4,12 @@ import {...@@ -4,7 +4,12 @@ import {
4 scopeProjectRef,4 scopeProjectRef,
5 scopeThreadRef,5 scopeThreadRef,
6} from "@t3tools/client-runtime/environment";6} from "@t3tools/client-runtime/environment";
7import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts";7import {
8 DEFAULT_RUNTIME_MODE,
9 DEFAULT_SERVER_SETTINGS,
10 type ScopedProjectRef,
11 type ThreadId,
12} from "@t3tools/contracts";
8import { useParams, useRouter } from "@tanstack/react-router";13import { useParams, useRouter } from "@tanstack/react-router";
9import { useCallback, useMemo } from "react";14import { useCallback, useMemo } from "react";
10import {15import {
...@@ -30,7 +35,7 @@ import {...@@ -30,7 +35,7 @@ import {
30 resolveNewThreadModelSelectionOverride,35 resolveNewThreadModelSelectionOverride,
31} from "../lib/chatThreadActions";36} from "../lib/chatThreadActions";
32import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";37import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
33import { primaryServerSettingsAtom } from "../state/server";38import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";
34import { resolveThreadRouteTarget } from "../threadRoutes";39import { resolveThreadRouteTarget } from "../threadRoutes";
35import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";40import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
36import { useClientSettings } from "./useSettings";41import { useClientSettings } from "./useSettings";
...@@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef...@@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
55}60}
5661
57export function useNewThreadHandler() {62export function useNewThreadHandler() {
58 // New-thread defaults are a user preference, and the settings UI only ever63 const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
59 // edits the primary environment's settings.json. Reading the target
60 // environment's own settings here would silently reset remote projects to
61 // the decoded defaults ("local" mode, current branch), since nothing can
62 // set those values on a remote server.
63 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);64 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
64 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);65 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
65 const router = useRouter();66 const router = useRouter();
...@@ -83,6 +84,8 @@ export function useNewThreadHandler() {...@@ -83,6 +84,8 @@ export function useNewThreadHandler() {
83 // up again and finding whichever draft it happens to hold.84 // up again and finding whichever draft it happens to hold.
84 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {85 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
85 const projects = readProjects();86 const projects = readProjects();
87 const targetServerSettings =
88 environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
86 const {89 const {
87 getComposerDraft,90 getComposerDraft,
88 getDraftSessionByLogicalProjectKey,91 getDraftSessionByLogicalProjectKey,
...@@ -138,7 +141,8 @@ export function useNewThreadHandler() {...@@ -138,7 +141,8 @@ export function useNewThreadHandler() {
138 );141 );
139 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>142 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
140 resolveNewThreadModelSelectionOverride({143 resolveNewThreadModelSelectionOverride({
141 projectDefaultSelection: project?.defaultModelSelection ?? null,144 projectDefaultSelection:
145 project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
142 carrySelection: carryModelSelection,146 carrySelection: carryModelSelection,
143 carrySourceDraftId:147 carrySourceDraftId:
144 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,148 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
...@@ -157,7 +161,7 @@ export function useNewThreadHandler() {...@@ -157,7 +161,7 @@ export function useNewThreadHandler() {
157 project.workspaceRoot,161 project.workspaceRoot,
158 )162 )
159 : null,163 : null,
160 globalDefault: primaryServerSettings.defaultThreadEnvMode,164 globalDefault: targetServerSettings.defaultThreadEnvMode,
161 });165 });
162 };166 };
163 const logicalProjectKey = project167 const logicalProjectKey = project
...@@ -429,7 +433,13 @@ export function useNewThreadHandler() {...@@ -429,7 +433,13 @@ export function useNewThreadHandler() {
429 return { draftId, threadId };433 return { draftId, threadId };
430 })();434 })();
431 },435 },
432 [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router],436 [
437 environmentServerConfigs,
438 getCurrentRouteTarget,
439 primaryServerSettings.newWorktreesStartFromOrigin,
440 projectGroupingSettings,
441 router,
442 ],
433 );443 );
434}444}
435445
apps/web/src/routeTree.gen.ts+21
...@@ -18,6 +18,7 @@ import { Route as ChatRouteImport } from './routes/_chat'...@@ -18,6 +18,7 @@ import { Route as ChatRouteImport } from './routes/_chat'
18import { Route as ChatIndexRouteImport } from './routes/_chat.index'18import { Route as ChatIndexRouteImport } from './routes/_chat.index'
19import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'19import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
20import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'20import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
21import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
21import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'22import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
22import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'23import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'
23import { Route as SettingsGeneralRouteImport } from './routes/settings.general'24import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
...@@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({...@@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
75 path: '/providers',76 path: '/providers',
76 getParentRoute: () => SettingsRoute,77 getParentRoute: () => SettingsRoute,
77} as any)78} as any)
79const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
80 id: '/projects',
81 path: '/projects',
82 getParentRoute: () => SettingsRoute,
83} as any)
78const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({84const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
79 id: '/keybindings',85 id: '/keybindings',
80 path: '/keybindings',86 path: '/keybindings',
...@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {...@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
154 '/settings/general': typeof SettingsGeneralRoute160 '/settings/general': typeof SettingsGeneralRoute
155 '/settings/integrations': typeof SettingsIntegrationsRoute161 '/settings/integrations': typeof SettingsIntegrationsRoute
156 '/settings/keybindings': typeof SettingsKeybindingsRoute162 '/settings/keybindings': typeof SettingsKeybindingsRoute
163 '/settings/projects': typeof SettingsProjectsRoute
157 '/settings/providers': typeof SettingsProvidersRoute164 '/settings/providers': typeof SettingsProvidersRoute
158 '/settings/source-control': typeof SettingsSourceControlRoute165 '/settings/source-control': typeof SettingsSourceControlRoute
159 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute166 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
...@@ -175,6 +182,7 @@ export interface FileRoutesByTo {...@@ -175,6 +182,7 @@ export interface FileRoutesByTo {
175 '/settings/general': typeof SettingsGeneralRoute182 '/settings/general': typeof SettingsGeneralRoute
176 '/settings/integrations': typeof SettingsIntegrationsRoute183 '/settings/integrations': typeof SettingsIntegrationsRoute
177 '/settings/keybindings': typeof SettingsKeybindingsRoute184 '/settings/keybindings': typeof SettingsKeybindingsRoute
185 '/settings/projects': typeof SettingsProjectsRoute
178 '/settings/providers': typeof SettingsProvidersRoute186 '/settings/providers': typeof SettingsProvidersRoute
179 '/settings/source-control': typeof SettingsSourceControlRoute187 '/settings/source-control': typeof SettingsSourceControlRoute
180 '/': typeof ChatIndexRoute188 '/': typeof ChatIndexRoute
...@@ -199,6 +207,7 @@ export interface FileRoutesById {...@@ -199,6 +207,7 @@ export interface FileRoutesById {
199 '/settings/general': typeof SettingsGeneralRoute207 '/settings/general': typeof SettingsGeneralRoute
200 '/settings/integrations': typeof SettingsIntegrationsRoute208 '/settings/integrations': typeof SettingsIntegrationsRoute
201 '/settings/keybindings': typeof SettingsKeybindingsRoute209 '/settings/keybindings': typeof SettingsKeybindingsRoute
210 '/settings/projects': typeof SettingsProjectsRoute
202 '/settings/providers': typeof SettingsProvidersRoute211 '/settings/providers': typeof SettingsProvidersRoute
203 '/settings/source-control': typeof SettingsSourceControlRoute212 '/settings/source-control': typeof SettingsSourceControlRoute
204 '/_chat/': typeof ChatIndexRoute213 '/_chat/': typeof ChatIndexRoute
...@@ -224,6 +233,7 @@ export interface FileRouteTypes {...@@ -224,6 +233,7 @@ export interface FileRouteTypes {
224 | '/settings/general'233 | '/settings/general'
225 | '/settings/integrations'234 | '/settings/integrations'
226 | '/settings/keybindings'235 | '/settings/keybindings'
236 | '/settings/projects'
227 | '/settings/providers'237 | '/settings/providers'
228 | '/settings/source-control'238 | '/settings/source-control'
229 | '/$environmentId/$threadId'239 | '/$environmentId/$threadId'
...@@ -245,6 +255,7 @@ export interface FileRouteTypes {...@@ -245,6 +255,7 @@ export interface FileRouteTypes {
245 | '/settings/general'255 | '/settings/general'
246 | '/settings/integrations'256 | '/settings/integrations'
247 | '/settings/keybindings'257 | '/settings/keybindings'
258 | '/settings/projects'
248 | '/settings/providers'259 | '/settings/providers'
249 | '/settings/source-control'260 | '/settings/source-control'
250 | '/'261 | '/'
...@@ -268,6 +279,7 @@ export interface FileRouteTypes {...@@ -268,6 +279,7 @@ export interface FileRouteTypes {
268 | '/settings/general'279 | '/settings/general'
269 | '/settings/integrations'280 | '/settings/integrations'
270 | '/settings/keybindings'281 | '/settings/keybindings'
282 | '/settings/projects'
271 | '/settings/providers'283 | '/settings/providers'
272 | '/settings/source-control'284 | '/settings/source-control'
273 | '/_chat/'285 | '/_chat/'
...@@ -351,6 +363,13 @@ declare module '@tanstack/react-router' {...@@ -351,6 +363,13 @@ declare module '@tanstack/react-router' {
351 preLoaderRoute: typeof SettingsProvidersRouteImport363 preLoaderRoute: typeof SettingsProvidersRouteImport
352 parentRoute: typeof SettingsRoute364 parentRoute: typeof SettingsRoute
353 }365 }
366 '/settings/projects': {
367 id: '/settings/projects'
368 path: '/projects'
369 fullPath: '/settings/projects'
370 preLoaderRoute: typeof SettingsProjectsRouteImport
371 parentRoute: typeof SettingsRoute
372 }
354 '/settings/keybindings': {373 '/settings/keybindings': {
355 id: '/settings/keybindings'374 id: '/settings/keybindings'
356 path: '/keybindings'375 path: '/keybindings'
...@@ -462,6 +481,7 @@ interface SettingsRouteChildren {...@@ -462,6 +481,7 @@ interface SettingsRouteChildren {
462 SettingsGeneralRoute: typeof SettingsGeneralRoute481 SettingsGeneralRoute: typeof SettingsGeneralRoute
463 SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute482 SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute
464 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute483 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
484 SettingsProjectsRoute: typeof SettingsProjectsRoute
465 SettingsProvidersRoute: typeof SettingsProvidersRoute485 SettingsProvidersRoute: typeof SettingsProvidersRoute
466 SettingsSourceControlRoute: typeof SettingsSourceControlRoute486 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
467}487}
...@@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {...@@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
474 SettingsGeneralRoute: SettingsGeneralRoute,494 SettingsGeneralRoute: SettingsGeneralRoute,
475 SettingsIntegrationsRoute: SettingsIntegrationsRoute,495 SettingsIntegrationsRoute: SettingsIntegrationsRoute,
476 SettingsKeybindingsRoute: SettingsKeybindingsRoute,496 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
497 SettingsProjectsRoute: SettingsProjectsRoute,
477 SettingsProvidersRoute: SettingsProvidersRoute,498 SettingsProvidersRoute: SettingsProvidersRoute,
478 SettingsSourceControlRoute: SettingsSourceControlRoute,499 SettingsSourceControlRoute: SettingsSourceControlRoute,
479}500}
apps/web/src/routes/projects.$projectKey.tsx+6-4
...@@ -1,15 +1,17 @@...@@ -1,15 +1,17 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";1import { createFileRoute, redirect } from "@tanstack/react-router";
22
3import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel";
4
5export const Route = createFileRoute("/projects/$projectKey")({3export const Route = createFileRoute("/projects/$projectKey")({
6 beforeLoad: async ({ context }) => {4 beforeLoad: async ({ context, params }) => {
7 if (5 if (
8 context.authGateState.status !== "authenticated" &&6 context.authGateState.status !== "authenticated" &&
9 context.authGateState.status !== "hosted-static"7 context.authGateState.status !== "hosted-static"
10 ) {8 ) {
11 throw redirect({ to: "/pair", replace: true });9 throw redirect({ to: "/pair", replace: true });
12 }10 }
11 throw redirect({
12 to: "/settings/projects",
13 search: { project: params.projectKey, machine: undefined },
14 replace: true,
15 });
13 },16 },
14 component: () => <ProjectSettingsPage projectKey={Route.useParams().projectKey} />,
15});17});
apps/web/src/routes/settings.projects.tsx created+27
...@@ -0,0 +1,27 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { ProjectsSettings } from "../components/settings/ProjectsSettings";
3
4export const Route = createFileRoute("/settings/projects")({
5 validateSearch: (search: Record<string, unknown>) => ({
6 project: typeof search.project === "string" ? search.project : undefined,
7 machine: typeof search.machine === "string" ? search.machine : undefined,
8 }),
9 component: ProjectsRoute,
10});
11
12function ProjectsRoute() {
13 const { project, machine } = Route.useSearch();
14 const navigate = Route.useNavigate();
15 return (
16 <ProjectsSettings
17 projectKey={project ?? null}
18 machineId={machine ?? null}
19 onScopeChange={(project, machine) => {
20 void navigate({
21 search: { project: project ?? undefined, machine: machine ?? undefined },
22 replace: true,
23 });
24 }}
25 />
26 );
27}
docs/user/project-settings.md+19-2
...@@ -1,11 +1,28 @@...@@ -1,11 +1,28 @@
1# Project settings1# Project settings
22
3Open **Settings → Projects** and select a project to change its preferences.3Open **Settings → Projects**. The project and machine pickers start at **All projects** and
4**All machines**.
5
6Change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values.
7Select an individual project to override a default. Reset its row to inherit again. Changing a
8default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence
9over machine defaults when the project has no explicit workspace override.
10
11Select a machine to limit edits to it. **All machines** writes defaults to connected machines;
12offline machines keep their previous values. Mixed values are indicated when selected machines
13or checkouts disagree. Browser access changes apply when an agent session next starts.
14
15Project grouping has a client-wide default across machines, with individual checkout overrides.
16Shared actions apply to inheriting projects; editing a project's actions creates an independent list.
17Reset that list to use shared actions again. Existing project actions are preserved.
18
19Project names, icons, removal, and importing actions from a checkout remain project-specific.
20When there are several checkouts, the checkout picker selects which actions and grouping to edit.
421
5## Project icons22## Project icons
623
7Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies24Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies
8to every checkout in the project group and appears on connected clients. Choose **Automatic** to25to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to
9let T3 Code detect an icon again.26let T3 Code detect an icon again.
1027
11## Keep the default branch current28## Keep the default branch current
packages/client-runtime/src/state/sharedSettings.test.ts+13-3
...@@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => {...@@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => {
44 sidebarAutoSettleOnMerge: false,44 sidebarAutoSettleOnMerge: false,
45 continueThreadsAfterServerUpdate: true,45 continueThreadsAfterServerUpdate: true,
46 enableAgentBrowserAccess: false,46 enableAgentBrowserAccess: false,
47 defaultThreadEnvMode: "worktree",
48 newWorktreesStartFromOrigin: true,
47 });49 });
48 expect(sharedPatch).toEqual({50 expect(sharedPatch).toEqual({
49 sidebarAutoSettleAfterDays: 7,51 sidebarAutoSettleAfterDays: 7,
50 sidebarAutoSettleOnMerge: false,52 sidebarAutoSettleOnMerge: false,
51 continueThreadsAfterServerUpdate: true,53 continueThreadsAfterServerUpdate: true,
54 newWorktreesStartFromOrigin: true,
55 });
56 expect(localPatch).toEqual({
57 enableAgentBrowserAccess: false,
58 defaultThreadEnvMode: "worktree",
52 });59 });
53 expect(localPatch).toEqual({ enableAgentBrowserAccess: false });
54 });60 });
55});61});
5662
...@@ -60,7 +66,6 @@ describe("pickSharedServerSettings", () => {...@@ -60,7 +66,6 @@ describe("pickSharedServerSettings", () => {
60 Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),66 Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
61 ).toEqual([67 ).toEqual([
62 "continueThreadsAfterServerUpdate",68 "continueThreadsAfterServerUpdate",
63 "defaultThreadEnvMode",
64 "newWorktreesStartFromOrigin",69 "newWorktreesStartFromOrigin",
65 "sidebarAutoSettleAfterDays",70 "sidebarAutoSettleAfterDays",
66 "sidebarAutoSettleOnMerge",71 "sidebarAutoSettleOnMerge",
...@@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => {...@@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => {
206 environmentId: boxId,211 environmentId: boxId,
207 label: "Remote Box",212 label: "Remote Box",
208 syncEligible: true,213 syncEligible: true,
209 settings: { ...primarySettings, enableAgentBrowserAccess: false },214 settings: {
215 ...primarySettings,
216 enableAgentBrowserAccess: false,
217 defaultThreadEnvMode:
218 primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local",
219 },
210 },220 },
211 ],221 ],
212 });222 });
packages/client-runtime/src/state/sharedSettings.ts-1
...@@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [...@@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [
24 "continueThreadsAfterServerUpdate",24 "continueThreadsAfterServerUpdate",
25 "sidebarAutoSettleAfterDays",25 "sidebarAutoSettleAfterDays",
26 "sidebarAutoSettleOnMerge",26 "sidebarAutoSettleOnMerge",
27 "defaultThreadEnvMode",
28 "newWorktreesStartFromOrigin",27 "newWorktreesStartFromOrigin",
29 "sourceControlWritingStyle",28 "sourceControlWritingStyle",
30] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>;29] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>;
packages/contracts/src/settings.ts+35-2
...@@ -2,7 +2,12 @@ import * as Effect from "effect/Effect";...@@ -2,7 +2,12 @@ import * as Effect from "effect/Effect";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import * as SchemaTransformation from "effect/SchemaTransformation";4import * as SchemaTransformation from "effect/SchemaTransformation";
5import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts";5import {
6 ForwardCompatibleNullable,
7 ProjectId,
8 TrimmedNonEmptyString,
9 TrimmedString,
10} from "./baseSchemas.ts";
6import { UsageLimitSourceId } from "./usageLimitSourceId.ts";11import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
7import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";12import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
8import {13import {
...@@ -11,7 +16,7 @@ import {...@@ -11,7 +16,7 @@ import {
11 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,16 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
12 ProviderOptionSelections,17 ProviderOptionSelections,
13} from "./model.ts";18} from "./model.ts";
14import { ModelSelection } from "./orchestration.ts";19import { ModelSelection, ProjectScript } from "./orchestration.ts";
15import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";20import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
16import {21import {
17 DEFAULT_PREVIEW_APPEARANCE,22 DEFAULT_PREVIEW_APPEARANCE,
...@@ -856,6 +861,22 @@ export const ServerSettings = Schema.Struct({...@@ -856,6 +861,22 @@ export const ServerSettings = Schema.Struct({
856 * between a desktop window and a phone attached to the same server.861 * between a desktop window and a phone attached to the same server.
857 */862 */
858 enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),863 enableAgentBrowserAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
864 projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe(
865 Schema.withDecodingDefault(Effect.succeed({})),
866 ),
867 defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
868 defaultProjectScripts: Schema.Array(ProjectScript).pipe(
869 Schema.withDecodingDefault(Effect.succeed([])),
870 ),
871 projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe(
872 Schema.withDecodingDefault(Effect.succeed({})),
873 ),
874 projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe(
875 Schema.withDecodingDefault(Effect.succeed({})),
876 ),
877 defaultModelSelection: Schema.NullOr(ModelSelection).pipe(
878 Schema.withDecodingDefault(Effect.succeed(null)),
879 ),
859 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(880 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
860 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),881 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
861 ),882 ),
...@@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({
1116 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),1137 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
1117 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),1138 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
1118 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),1139 enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
1140 projectAgentBrowserAccessOverrides: Schema.optionalKey(
1141 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
1142 ),
1143 defaultAutoPull: Schema.optionalKey(Schema.Boolean),
1144 defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)),
1145 projectScriptOverrides: Schema.optionalKey(
1146 Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))),
1147 ),
1148 projectAutoPullOverrides: Schema.optionalKey(
1149 Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
1150 ),
1151 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
1119 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1152 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1120 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),1153 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
1121 backgroundActivity: Schema.optionalKey(1154 backgroundActivity: Schema.optionalKey(
packages/shared/src/projectScripts.ts+21-1
...@@ -1,4 +1,24 @@...@@ -1,4 +1,24 @@
1import type { ProjectScript } from "@t3tools/contracts";1import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts";
2
3/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */
4export function resolveProjectScripts(
5 settings: Pick<ServerSettings, "defaultProjectScripts" | "projectScriptOverrides">,
6 project: { id: ProjectId; scripts: readonly ProjectScript[] },
7): readonly ProjectScript[] {
8 const override = settings.projectScriptOverrides[project.id];
9 if (override === null) return settings.defaultProjectScripts;
10 return (
11 override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts)
12 );
13}
14
15export function projectScriptsInheritDefaults(
16 settings: Pick<ServerSettings, "projectScriptOverrides">,
17 project: { id: ProjectId; scripts: readonly ProjectScript[] },
18): boolean {
19 const override = settings.projectScriptOverrides[project.id];
20 return override === null || (override === undefined && project.scripts.length === 0);
21}
222
3interface ProjectScriptRuntimeEnvInput {23interface ProjectScriptRuntimeEnvInput {
4 project: {24 project: {
packages/shared/src/serverSettings.test.ts+168
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_SERVER_SETTINGS,2 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
3 ProviderDriverKind,4 ProviderDriverKind,
4 ProviderInstanceId,5 ProviderInstanceId,
5 UsageLimitSourceId,6 UsageLimitSourceId,
...@@ -9,14 +10,181 @@ import * as Duration from "effect/Duration";...@@ -9,14 +10,181 @@ import * as Duration from "effect/Duration";
9import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
10import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";11import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
11import { createModelSelection } from "./model.ts";12import { createModelSelection } from "./model.ts";
13import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
12import {14import {
13 applyServerSettingsPatch,15 applyServerSettingsPatch,
14 isModelSelectionProviderEnabled,16 isModelSelectionProviderEnabled,
15 parsePersistedServerObservabilitySettings,17 parsePersistedServerObservabilitySettings,
16 resolveSourceControlWriterModelSelection,18 resolveSourceControlWriterModelSelection,
19 resolveProjectAgentBrowserAccess,
20 resolveProjectAutoPull,
17} from "./serverSettings.ts";21} from "./serverSettings.ts";
1822
19describe("serverSettings helpers", () => {23describe("serverSettings helpers", () => {
24 it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
25 const project = { id: ProjectId.make("project-actions"), scripts: [] };
26 const action = {
27 id: "check",
28 name: "Check",
29 command: "npm test",
30 icon: "play" as const,
31 runOnWorktreeCreate: false,
32 };
33 const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
34 defaultProjectScripts: [action],
35 });
36 expect(resolveProjectScripts(defaults, project)).toEqual([action]);
37 expect(projectScriptsInheritDefaults(defaults, project)).toBe(true);
38 const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
39 expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts);
40 expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false);
41 const disabled = applyServerSettingsPatch(defaults, {
42 projectScriptOverrides: { [project.id]: [] },
43 });
44 expect(resolveProjectScripts(disabled, project)).toEqual([]);
45 expect(projectScriptsInheritDefaults(disabled, project)).toBe(false);
46 const changedDefault = applyServerSettingsPatch(disabled, {
47 defaultProjectScripts: [{ ...action, command: "npm run build" }],
48 });
49 expect(resolveProjectScripts(changedDefault, project)).toEqual([]);
50 const reset = applyServerSettingsPatch(changedDefault, {
51 projectScriptOverrides: { [project.id]: null },
52 });
53 expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts);
54 expect(projectScriptsInheritDefaults(reset, existing)).toBe(true);
55 expect(
56 resolveProjectScripts(
57 applyServerSettingsPatch(reset, { defaultProjectScripts: [] }),
58 existing,
59 ),
60 ).toEqual([]);
61 });
62
63 it("preserves other projects' actions when overriding, clearing, or resetting one project", () => {
64 const firstProject = { id: ProjectId.make("first-project"), scripts: [] };
65 const secondProject = { id: ProjectId.make("second-project"), scripts: [] };
66 const defaultAction = {
67 id: "check",
68 name: "Check",
69 command: "npm test",
70 icon: "play" as const,
71 runOnWorktreeCreate: false,
72 };
73 const firstAction = { ...defaultAction, command: "npm run lint" };
74 const secondAction = { ...defaultAction, command: "npm run build" };
75 const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
76 defaultProjectScripts: [defaultAction],
77 projectScriptOverrides: { [firstProject.id]: [firstAction] },
78 });
79 const secondUpdate = applyServerSettingsPatch(firstUpdate, {
80 projectScriptOverrides: { [secondProject.id]: [secondAction] },
81 });
82 expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
83 expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]);
84
85 const cleared = applyServerSettingsPatch(secondUpdate, {
86 projectScriptOverrides: { [firstProject.id]: [] },
87 });
88 expect(resolveProjectScripts(cleared, firstProject)).toEqual([]);
89 expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]);
90
91 const reset = applyServerSettingsPatch(cleared, {
92 projectScriptOverrides: { [firstProject.id]: null },
93 });
94 expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([
95 defaultAction,
96 ]);
97 expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]);
98 expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
99 });
100
101 it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => {
102 const projectId = ProjectId.make("project-pull");
103 expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false);
104 expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true);
105 const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true });
106 expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true);
107 const overridden = applyServerSettingsPatch(enabled, {
108 projectAutoPullOverrides: { [projectId]: false },
109 });
110 expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false);
111 const reset = applyServerSettingsPatch(overridden, {
112 projectAutoPullOverrides: { [projectId]: null },
113 });
114 expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true);
115 const disabled = applyServerSettingsPatch(reset, {
116 defaultAutoPull: false,
117 projectAutoPullOverrides: { [projectId]: true },
118 });
119 expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true);
120 expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false);
121 });
122
123 it("inherits browser access and restores inheritance when a project override is removed", () => {
124 const projectId = ProjectId.make("project-browser");
125 const otherProjectId = ProjectId.make("other-project");
126 const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
127 projectAgentBrowserAccessOverrides: { [projectId]: false },
128 });
129 expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false);
130 expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true);
131 const reset = applyServerSettingsPatch(overridden, {
132 projectAgentBrowserAccessOverrides: { [projectId]: null },
133 });
134 expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true);
135 const enabled = applyServerSettingsPatch(reset, {
136 enableAgentBrowserAccess: false,
137 projectAgentBrowserAccessOverrides: { [projectId]: true },
138 });
139 expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true);
140 expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false);
141 });
142
143 it("preserves other projects' boolean overrides across separate updates and resets", () => {
144 const firstProjectId = ProjectId.make("first-project");
145 const secondProjectId = ProjectId.make("second-project");
146 const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
147 defaultAutoPull: true,
148 projectAutoPullOverrides: { [firstProjectId]: false },
149 projectAgentBrowserAccessOverrides: { [firstProjectId]: false },
150 });
151 const secondUpdate = applyServerSettingsPatch(firstUpdate, {
152 projectAutoPullOverrides: { [secondProjectId]: false },
153 projectAgentBrowserAccessOverrides: { [secondProjectId]: false },
154 });
155 for (const projectId of [firstProjectId, secondProjectId]) {
156 expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false);
157 expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false);
158 }
159
160 const reset = applyServerSettingsPatch(secondUpdate, {
161 projectAutoPullOverrides: { [firstProjectId]: null },
162 projectAgentBrowserAccessOverrides: { [firstProjectId]: null },
163 });
164 expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true);
165 expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true);
166 expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false);
167 expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false);
168 expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined();
169 expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined();
170 expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false);
171 expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false);
172 });
173
174 it("replaces and clears conversation model defaults without retaining old options", () => {
175 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
176 defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
177 { id: "reasoningEffort", value: "high" },
178 ]),
179 });
180 const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet");
181 const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection });
182 expect(updated.defaultModelSelection).toEqual(selection);
183 expect(
184 applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection,
185 ).toBeNull();
186 });
187
20 it("ignores missing and blank persisted observability URLs", () => {188 it("ignores missing and blank persisted observability URLs", () => {
21 expect(parsePersistedServerObservabilitySettings("{}")).toEqual({189 expect(parsePersistedServerObservabilitySettings("{}")).toEqual({
22 otlpTracesUrl: undefined,190 otlpTracesUrl: undefined,
packages/shared/src/serverSettings.ts+54
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 isProviderAvailable,3 isProviderAvailable,
4 resolveProviderInstanceEnabled,4 resolveProviderInstanceEnabled,
5 type ModelSelection,5 type ModelSelection,
6 type ProjectId,
6 type ProviderDriverKind,7 type ProviderDriverKind,
7 type ServerProvider,8 type ServerProvider,
8 ServerSettings,9 ServerSettings,
...@@ -23,6 +24,27 @@ import {...@@ -23,6 +24,27 @@ import {
23const ServerSettingsJson = fromLenientJson(ServerSettings);24const ServerSettingsJson = fromLenientJson(ServerSettings);
24const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);25const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson);
2526
27export function resolveProjectAgentBrowserAccess(
28 settings: Pick<ServerSettings, "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides">,
29 projectId: ProjectId,
30): boolean {
31 return (
32 settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess
33 );
34}
35
36export function resolveProjectAutoPull(
37 settings: Pick<ServerSettings, "defaultAutoPull" | "projectAutoPullOverrides">,
38 projectId: ProjectId,
39 legacyAutoPull: boolean | undefined,
40): boolean {
41 // Existing opt-ins stay enabled until explicitly overridden or reset.
42 return (
43 settings.projectAutoPullOverrides[projectId] ??
44 (legacyAutoPull === true || settings.defaultAutoPull)
45 );
46}
47
26type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];48type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
2749
28const getLegacyProviderSettings = (50const getLegacyProviderSettings = (
...@@ -151,6 +173,8 @@ export function applyServerSettingsPatch(...@@ -151,6 +173,8 @@ export function applyServerSettingsPatch(
151 // Merged per entry below; its `null` removals must not reach deepMerge.173 // Merged per entry below; its `null` removals must not reach deepMerge.
152 usageLimitSources: usageLimitSourcesPatch,174 usageLimitSources: usageLimitSourcesPatch,
153 usagePriceOverrides: usagePriceOverridesPatch,175 usagePriceOverrides: usagePriceOverridesPatch,
176 projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,
177 projectAutoPullOverrides: projectAutoPullOverridesPatch,
154 ...patchForMerge178 ...patchForMerge
155 } = patch;179 } = patch;
156 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);180 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
...@@ -207,6 +231,36 @@ export function applyServerSettingsPatch(...@@ -207,6 +231,36 @@ export function applyServerSettingsPatch(
207 ...(patch.providerInstances !== undefined231 ...(patch.providerInstances !== undefined
208 ? { providerInstances: patch.providerInstances }232 ? { providerInstances: patch.providerInstances }
209 : {}),233 : {}),
234 ...(projectAgentBrowserAccessOverridesPatch !== undefined
235 ? {
236 projectAgentBrowserAccessOverrides: mergeSettingsEntries(
237 current.projectAgentBrowserAccessOverrides,
238 projectAgentBrowserAccessOverridesPatch,
239 ),
240 }
241 : {}),
242 ...(projectAutoPullOverridesPatch !== undefined
243 ? {
244 projectAutoPullOverrides: mergeSettingsEntries(
245 current.projectAutoPullOverrides,
246 projectAutoPullOverridesPatch,
247 ),
248 }
249 : {}),
250 ...(patch.defaultModelSelection !== undefined
251 ? { defaultModelSelection: patch.defaultModelSelection }
252 : {}),
253 ...(patch.defaultProjectScripts !== undefined
254 ? { defaultProjectScripts: patch.defaultProjectScripts }
255 : {}),
256 ...(patch.projectScriptOverrides !== undefined
257 ? {
258 projectScriptOverrides: {
259 ...current.projectScriptOverrides,
260 ...patch.projectScriptOverrides,
261 },
262 }
263 : {}),
210 ...(usageLimitSourcesPatch !== undefined264 ...(usageLimitSourcesPatch !== undefined
211 ? {265 ? {
212 usageLimitSources: mergeSettingsEntries(266 usageLimitSources: mergeSettingsEntries(