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 {
77} from "@react-navigation/native";
88import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
99import * 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";
1116import {
1217 requestOlderThreadTurns,
1318 threadHasOlderTurns,
1419} 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";
1625import { Platform, ScrollView, View } from "react-native";
1726import { useSafeAreaInsets } from "react-native-safe-area-context";
1827import { useWorkspaceState } from "../../state/workspace";
......@@ -627,7 +636,12 @@ function ThreadRouteContent(
627636 gitOperationLabel: gitState.gitOperationLabel,
628637 canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot),
629638 canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
630 projectScripts: selectedThreadProject?.scripts ?? [],
639 projectScripts: selectedThreadProject
640 ? resolveProjectScripts(
641 routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS,
642 selectedThreadProject,
643 )
644 : [],
631645 terminalSessions: terminalMenuSessions,
632646 showDirectFileControl: layout.usesSplitView,
633647 onOpenTerminal: handleOpenTerminal,
......@@ -819,6 +833,7 @@ function ThreadRouteContent(
819833 <>
820834 {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null}
821835 <NativeStackScreenOptions
836 optionsVersion={threadGitControlProps.projectScripts}
822837 options={{
823838 // Android draws its own in-flow header (AndroidScreenHeader below);
824839 // 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) {
427427 );
428428 const projectDefaultModelSelection = resolveDefaultableModelSelection(
429429 selectedEnvironmentServerConfig,
430 selectedProject?.defaultModelSelection ?? null,
430 selectedProject?.defaultModelSelection ??
431 selectedEnvironmentServerConfig?.settings.defaultModelSelection ??
432 null,
431433 );
432434 const storedStickyModelSelection = useStickyComposerModelSelection();
433435 const stickyModelSelection = resolveDefaultableModelSelection(
apps/server/src/project/ProjectSetupScriptRunner.test.ts+61
......@@ -6,6 +6,7 @@ import * as Option from "effect/Option";
66import * as Schema from "effect/Schema";
77
88import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
9import * as ServerSettings from "../serverSettings.ts";
910import * as TerminalManager from "../terminal/Manager.ts";
1011import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
1112
......@@ -68,13 +69,73 @@ const makeTerminalManagerLayer = (
6869const testLayer = (
6970 project: OrchestrationProject,
7071 terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">,
72 settings = ServerSettings.layerTest(),
7173) =>
7274 ProjectSetupScriptRunner.layer.pipe(
7375 Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
7476 Layer.provideMerge(makeTerminalManagerLayer(terminal)),
77 Layer.provide(settings),
7578 );
7679
7780describe("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
78139 it.effect("returns no-script when no setup script exists", () => {
79140 const open = vi.fn(() => Effect.die("unexpected open"));
80141 const write = vi.fn(() => Effect.die("unexpected write"));
apps/server/src/project/ProjectSetupScriptRunner.ts+19-3
......@@ -1,5 +1,9 @@
11import { ProjectId } from "@t3tools/contracts";
2import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts";
2import {
3 projectScriptRuntimeEnv,
4 resolveProjectScripts,
5 setupProjectScript,
6} from "@t3tools/shared/projectScripts";
37import * as Context from "effect/Context";
48import * as Effect from "effect/Effect";
59import * as Layer from "effect/Layer";
......@@ -7,6 +11,7 @@ import * as Option from "effect/Option";
711import * as Schema from "effect/Schema";
812
913import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
14import * as ServerSettings from "../serverSettings.ts";
1015import * as TerminalManager from "../terminal/Manager.ts";
1116
1217export interface ProjectSetupScriptRunnerResultNoScript {
......@@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass<Pr
4045 projectId: Schema.optional(Schema.String),
4146 projectCwd: Schema.optional(Schema.String),
4247 worktreePath: Schema.String,
43 operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]),
48 operation: Schema.Literals(["resolveProject", "readSettings", "openTerminal", "writeCommand"]),
4449 cause: Schema.Defect(),
4550 },
4651) {
......@@ -81,6 +86,7 @@ export class ProjectSetupScriptRunner extends Context.Service<
8186export const make = Effect.gen(function* () {
8287 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
8388 const terminalManager = yield* TerminalManager.TerminalManager;
89 const serverSettings = yield* ServerSettings.ServerSettingsService;
8490
8591 const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn(
8692 "ProjectSetupScriptRunner.runForThread",
......@@ -124,7 +130,17 @@ export const make = Effect.gen(function* () {
124130 return yield* new ProjectSetupScriptProjectNotFoundError(errorContext);
125131 }
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));
128144 if (!script) {
129145 return {
130146 status: "no-script",
apps/server/src/provider/Layers/ProviderService.test.ts+80-2
......@@ -19,6 +19,8 @@ import {
1919 EnvironmentId,
2020 EventId,
2121 MessageId,
22 OrchestrationThreadShell,
23 ProjectId,
2224 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
2325 ProviderDriverKind,
2426 ProviderInstanceId,
......@@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts";
7577import * as ServerSettings from "../../serverSettings.ts";
7678import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
7779import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts";
80import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
7881
7982const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest();
8083const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe(
......@@ -4302,10 +4305,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => {
43024305 );
43034306});
43044307
4308const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell);
4309
43054310describe("agent browser access", () => {
43064311 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 ) =>
43094319 Effect.gen(function* () {
43104320 const issued: Array<ThreadId> = [];
43114321 const codex = makeFakeCodexAdapter();
......@@ -4319,6 +4329,49 @@ describe("agent browser access", () => {
43194329 const directoryLayer = ProviderSessionDirectoryLive.pipe(
43204330 Layer.provide(runtimeRepositoryLayer),
43214331 );
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 });
43224375 const providerLayer = makeProviderServiceLive({
43234376 issueMcpCredential: (request) =>
43244377 Effect.sync(() => {
......@@ -4329,7 +4382,14 @@ describe("agent browser access", () => {
43294382 }).pipe(
43304383 Layer.provide(providerAdapterLayer),
43314384 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 ),
43334393 Layer.provide(serverConfigTestLayer),
43344394 Layer.provide(AnalyticsService.layerTest),
43354395 Layer.provide(
......@@ -4387,4 +4447,22 @@ describe("agent browser access", () => {
43874447 assert.deepEqual(issued, [threadId]);
43884448 }).pipe(Effect.provide(NodeServices.layer)),
43894449 );
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 );
43904468});
apps/server/src/provider/Layers/ProviderService.ts+19-3
......@@ -32,6 +32,7 @@ import {
3232import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
3333import { causeErrorTag } from "@t3tools/shared/observability";
3434import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
35import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings";
3536import * as DateTime from "effect/DateTime";
3637import * as Deferred from "effect/Deferred";
3738import * as Effect from "effect/Effect";
......@@ -72,6 +73,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
7273import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
7374import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
7475import * as ServerSettings from "../../serverSettings.ts";
76import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
7577const isModelSelection = Schema.is(ModelSelection);
7678
7779interface PendingCompaction {
......@@ -323,6 +325,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
323325 const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
324326 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
325327 const serverSettings = yield* ServerSettings.ServerSettingsService;
328 const projectionQuery = yield* Effect.serviceOption(
329 ProjectionSnapshotQuery.ProjectionSnapshotQuery,
330 );
326331 const issueMcpCredential =
327332 options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
328333 const revokeMcpCredential =
......@@ -714,8 +719,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
714719 * "off" silently becoming "on" would violate the user's stated choice,
715720 * whereas the reverse costs an agent one toolset and is visible immediately.
716721 */
717 const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(
718 Effect.map((settings) => settings.enableAgentBrowserAccess),
722 const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")(
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 },
719735 Effect.catch((cause) =>
720736 Effect.logWarning(
721737 "Could not read server settings; withholding agent browser access for this session.",
......@@ -726,7 +742,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
726742
727743 const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
728744 Effect.gen(function* () {
729 if (!(yield* agentBrowserAccessEnabled)) {
745 if (!(yield* agentBrowserAccessEnabled(threadId))) {
730746 // Revoke as well as clear. Every other prepare path reaches
731747 // `issueActiveMcpCredential`, which revokes the thread first, so
732748 // 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(
317317);
318318
319319const GitManagerLayerLive = GitManager.layer.pipe(
320 Layer.provideMerge(ProjectSetupScriptRunner.layer),
320 Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))),
321321 Layer.provideMerge(GitVcsDriver.layer),
322322 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
323323 Layer.provideMerge(TextGeneration.layer),
......@@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe(
353353 Layer.provideMerge(
354354 VcsStatusBroadcaster.layer.pipe(
355355 Layer.provide(GitWorkflowLayerLive),
356 Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer),
356 Layer.provide(
357 VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)),
358 ),
357359 ),
358360 ),
359361);
apps/server/src/serverRuntimeStartup.test.ts+54-10
......@@ -14,6 +14,7 @@ import * as ServerConfig from "./config.ts";
1414import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
1515import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
1616import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
17import * as ServerSettings from "./serverSettings.ts";
1718import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
1819
1920it.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
4041 }),
4142 } as unknown as GitVcsDriver.GitVcsDriver["Service"];
4243 const project = (workspaceRoot: string, autoPull = true) =>
43 ({ workspaceRoot, autoPull }) as never;
44 ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never;
4445
4546 yield* ServerRuntimeStartup.autoPullProjects([
4647 project("/clean"),
......@@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che
5253 ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
5354
5455 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"]);
5566 }),
5667);
5768
......@@ -124,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
124135 return Effect.gen(function* () {
125136 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
126137 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
138 Effect.provide(ServerSettings.layerTest()),
127139 Effect.provideService(ServerConfig.ServerConfig, {
128140 cwd: "/tmp/startup-project",
129141 autoBootstrapProjectFromCwd: true,
......@@ -189,8 +201,19 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
189201 });
190202});
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 }) =>
193210 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;
194217 const dispatchCalls = yield* Ref.make<
195218 ReadonlyArray<{
196219 readonly type: string;
......@@ -199,6 +222,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
199222 }>
200223 >([]);
201224 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
225 Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })),
202226 Effect.provideService(ServerConfig.ServerConfig, {
203227 cwd: "/tmp/startup-project",
204228 autoBootstrapProjectFromCwd: true,
......@@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
212236 getSnapshotSequence: () => Effect.die("unused"),
213237 getCounts: () => Effect.die("unused"),
214238 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 ),
216254 getProjectShellById: () => Effect.die("unused"),
217255 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
218256 getImportedAgentSessionSources: () => Effect.die("unused"),
......@@ -241,18 +279,22 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
241279
242280 assert.equal(typeof targets.bootstrapProjectId, "string");
243281 assert.equal(typeof targets.bootstrapThreadId, "string");
244 assert.equal(targets.bootstrapProjectCreated, true);
282 assert.equal(targets.bootstrapProjectCreated, !existing);
245283 assert.equal(targets.bootstrapThreadCreated, true);
246284 const commands = yield* Ref.get(dispatchCalls);
247285 assert.deepStrictEqual(
248286 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 },
250297 );
251 assert.equal("defaultModelSelection" in commands[0]!, false);
252 assert.deepStrictEqual(commands[1]?.modelSelection, {
253 instanceId: ProviderInstanceId.make("codex"),
254 model: DEFAULT_MODEL,
255 });
256298 }),
257299);
258300
......@@ -262,6 +304,7 @@ it.effect(
262304 Effect.gen(function* () {
263305 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
264306 const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
307 Effect.provide(ServerSettings.layerTest()),
265308 Effect.provideService(ServerConfig.ServerConfig, {
266309 cwd: "/tmp/startup-project",
267310 autoBootstrapProjectFromCwd: true,
......@@ -322,6 +365,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
322365 const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]);
323366
324367 const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe(
368 Effect.provide(ServerSettings.layerTest()),
325369 Effect.provideService(ServerConfig.ServerConfig, {
326370 cwd: "/tmp/startup-project",
327371 autoBootstrapProjectFromCwd: true,
apps/server/src/serverRuntimeStartup.ts+17-4
......@@ -2,6 +2,7 @@ import {
22 CommandId,
33 DEFAULT_MODEL,
44 DEFAULT_PROVIDER_INTERACTION_MODE,
5 DEFAULT_SERVER_SETTINGS,
56 type ModelSelection,
67 type OrchestrationProjectShell,
78 ProjectId,
......@@ -9,6 +10,7 @@ import {
910 ThreadId,
1011 TurnId,
1112} from "@t3tools/contracts";
13import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
1214import * as Cause from "effect/Cause";
1315import * as Console from "effect/Console";
1416import * as Context from "effect/Context";
......@@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
198200 let bootstrapThreadCreated = false;
199201
200202 if (serverConfig.autoBootstrapProjectFromCwd) {
203 const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings;
204 const defaultModelSelection =
205 settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
201206 yield* Effect.gen(function* () {
202207 const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot(
203208 serverConfig.cwd,
......@@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
209214 const createdAt = DateTime.formatIso(yield* DateTime.now);
210215 nextProjectId = ProjectId.make(yield* randomUUID);
211216 const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project";
212 nextThreadModelSelection = getAutoBootstrapThreadModelSelection();
217 nextThreadModelSelection = defaultModelSelection;
213218 yield* orchestrationEngine.dispatch({
214219 type: "project.create",
215220 commandId: CommandId.make(yield* randomUUID),
......@@ -224,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
224229 nextProjectId = existingProject.value.id;
225230 bootstrapProjectId = nextProjectId;
226231 nextThreadModelSelection =
227 existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection();
232 existingProject.value.defaultModelSelection ?? defaultModelSelection;
228233 }
229234
230235 yield* Effect.gen(function* () {
......@@ -737,12 +742,16 @@ interface StartupOptions {
737742
738743export const autoPullProjects = Effect.fn("autoPullProjects")(function* (
739744 projects: ReadonlyArray<OrchestrationProjectShell>,
745 settings: Pick<
746 typeof DEFAULT_SERVER_SETTINGS,
747 "defaultAutoPull" | "projectAutoPullOverrides"
748 > = DEFAULT_SERVER_SETTINGS,
740749) {
741750 const git = yield* GitVcsDriver.GitVcsDriver;
742751 const workspaceRoots = [
743752 ...new Set(
744753 projects
745 .filter((project) => project.autoPull === true)
754 .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull))
746755 .map((project) => project.workspaceRoot),
747756 ),
748757 ];
......@@ -813,7 +822,11 @@ export const make = (options?: StartupOptions) =>
813822 const reactorScope = yield* Scope.make("sequential");
814823
815824 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 ),
817830 Effect.catch((cause) =>
818831 Effect.logWarning("Failed to load projects for automatic pull", { cause }),
819832 ),
apps/server/src/vcs/VcsStatusBroadcaster.ts+12-5
......@@ -22,10 +22,12 @@ import type {
2222 VcsStatusStreamEvent,
2323} from "@t3tools/contracts";
2424import { mergeGitStatusParts } from "@t3tools/shared/git";
25import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
2526
2627import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2728import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2829import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
30import * as ServerSettings from "../serverSettings.ts";
2931
3032const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
3133const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);
......@@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect(
151153 VcsAutoPullPolicy,
152154 Effect.gen(function* () {
153155 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
156 const serverSettings = yield* ServerSettings.ServerSettingsService;
154157 return {
155 isEnabled: (cwd: string) =>
156 snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe(
157 Effect.map((project) => project._tag === "Some" && project.value.autoPull === true),
158 Effect.orElseSucceed(() => false),
159 ),
158 isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")(
159 function* (cwd: string) {
160 const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd);
161 if (project._tag === "None") return false;
162 const settings = yield* serverSettings.getSettings;
163 return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull);
164 },
165 Effect.orElseSucceed(() => false),
166 ),
160167 };
161168 }),
162169);
apps/web/src/components/ChatView.tsx+53-36
......@@ -53,7 +53,11 @@ import {
5353 createModelSelection,
5454 resolvePromptInjectedEffort,
5555} from "@t3tools/shared/model";
56import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
56import {
57 projectScriptCwd,
58 projectScriptRuntimeEnv,
59 resolveProjectScripts,
60} from "@t3tools/shared/projectScripts";
5761import { truncate } from "@t3tools/shared/String";
5862import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
5963import {
......@@ -276,7 +280,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi
276280import { environmentCatalog } from "../connection/catalog";
277281import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
278282import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
279import { projectEnvironment } from "../state/projects";
280283import { useEnvironmentQuery } from "../state/query";
281284import {
282285 environmentServerConfigsAtom,
......@@ -1386,7 +1389,9 @@ export default function ChatView(props: ChatViewProps) {
13861389 [environmentId, threadId],
13871390 );
13881391 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
1389 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
1392 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {
1393 reportFailure: false,
1394 });
13901395 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
13911396 reportFailure: false,
13921397 });
......@@ -1474,9 +1479,6 @@ export default function ChatView(props: ChatViewProps) {
14741479 }, [routeKind, routeThreadRef, routeThreadState]);
14751480 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
14761481 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.
14801482 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
14811483 const setStickyComposerModelSelection = useComposerDraftStore(
14821484 (store) => store.setStickyModelSelection,
......@@ -1758,10 +1760,17 @@ export default function ChatView(props: ChatViewProps) {
17581760 ? buildLocalDraftThread(
17591761 threadId,
17601762 draftThread,
1761 fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,
1763 fallbackDraftProject?.defaultModelSelection ??
1764 settings.defaultModelSelection ??
1765 NO_PROVIDER_MODEL_SELECTION,
17621766 )
17631767 : undefined,
1764 [draftThread, fallbackDraftProject?.defaultModelSelection, threadId],
1768 [
1769 draftThread,
1770 fallbackDraftProject?.defaultModelSelection,
1771 settings.defaultModelSelection,
1772 threadId,
1773 ],
17651774 );
17661775 // Promotion is data-driven: the draft route keeps rendering while the
17671776 // server thread (same pre-allocated ref) starts, so live state must not
......@@ -1987,6 +1996,12 @@ export default function ChatView(props: ChatViewProps) {
19871996 [activeThread?.environmentId, activeThread?.projectId],
19881997 );
19891998 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;
19902005 const handleNewThreadInActiveProject = useCallback(() => {
19912006 startNewThreadForProject(activeProjectRef, handleNewThread);
19922007 }, [activeProjectRef, handleNewThread]);
......@@ -2035,8 +2050,8 @@ export default function ChatView(props: ChatViewProps) {
20352050 [activeProjectKey],
20362051 );
20372052 const configuredPreviewUrls = useMemo(
2038 () => getConfiguredPreviewUrls(activeProject?.scripts),
2039 [activeProject?.scripts],
2053 () => getConfiguredPreviewUrls(activeProjectScripts),
2054 [activeProjectScripts],
20402055 );
20412056
20422057 useEffect(() => {
......@@ -2280,7 +2295,7 @@ export default function ChatView(props: ChatViewProps) {
22802295 const selectedProviderByThreadId = composerActiveProvider ?? null;
22812296 const threadProvider =
22822297 activeThread?.modelSelection.instanceId ??
2283 activeProject?.defaultModelSelection?.instanceId ??
2298 activeProjectDefaultModelSelection?.instanceId ??
22842299 null;
22852300 const lockedProvider = deriveLockedProvider({
22862301 thread: activeThread,
......@@ -2514,14 +2529,14 @@ export default function ChatView(props: ChatViewProps) {
25142529 selectedProviderByThreadId,
25152530 activeThread?.session?.providerInstanceId,
25162531 activeThread?.modelSelection.instanceId,
2517 activeProject?.defaultModelSelection?.instanceId,
2532 activeProjectDefaultModelSelection?.instanceId,
25182533 ],
25192534 lockedProvider,
25202535 lockedInstanceId:
25212536 activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId,
25222537 }),
25232538 [
2524 activeProject?.defaultModelSelection?.instanceId,
2539 activeProjectDefaultModelSelection?.instanceId,
25252540 activeThread?.modelSelection.instanceId,
25262541 activeThread?.session?.providerInstanceId,
25272542 lockedProvider,
......@@ -3652,11 +3667,14 @@ export default function ChatView(props: ChatViewProps) {
36523667 keybindingCommand: KeybindingCommand;
36533668 }): Promise<AtomCommandResult<void, unknown>> => {
36543669 const updateResult = mapAtomCommandResult(
3655 await updateProject({
3670 await updateProjectScriptSettings({
36563671 environmentId,
36573672 input: {
3658 projectId: input.projectId,
3659 scripts: input.nextScripts,
3673 patch: {
3674 projectScriptOverrides: {
3675 [input.projectId]: input.nextScripts,
3676 },
3677 },
36603678 },
36613679 }),
36623680 () => undefined,
......@@ -3681,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) {
36813699 }
36823700 return updateResult;
36833701 },
3684 [environmentId, updateProject, upsertKeybinding],
3702 [environmentId, updateProjectScriptSettings, upsertKeybinding],
36853703 );
36863704 const saveProjectScript = useCallback(
36873705 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {
......@@ -3690,28 +3708,28 @@ export default function ChatView(props: ChatViewProps) {
36903708 }
36913709 const nextId = nextProjectScriptId(
36923710 input.name,
3693 activeProject.scripts.map((script) => script.id),
3711 activeProjectScripts.map((script) => script.id),
36943712 );
36953713 const nextScript = buildProjectScript(nextId, input);
36963714 const nextScripts = input.runOnWorktreeCreate
36973715 ? [
3698 ...activeProject.scripts.map((script) =>
3716 ...activeProjectScripts.map((script) =>
36993717 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,
37003718 ),
37013719 nextScript,
37023720 ]
3703 : [...activeProject.scripts, nextScript];
3721 : [...activeProjectScripts, nextScript];
37043722
37053723 return persistProjectScripts({
37063724 projectId: activeProject.id,
37073725 projectCwd: activeProject.workspaceRoot,
3708 previousScripts: activeProject.scripts,
3726 previousScripts: activeProjectScripts,
37093727 nextScripts,
37103728 keybinding: input.keybinding,
37113729 keybindingCommand: commandForProjectScript(nextId),
37123730 });
37133731 },
3714 [activeProject, persistProjectScripts],
3732 [activeProject, activeProjectScripts, persistProjectScripts],
37153733 );
37163734 const updateProjectScript = useCallback(
37173735 async (
......@@ -3721,13 +3739,13 @@ export default function ChatView(props: ChatViewProps) {
37213739 if (!activeProject) {
37223740 return AsyncResult.success(undefined);
37233741 }
3724 const existingScript = activeProject.scripts.find((script) => script.id === scriptId);
3742 const existingScript = activeProjectScripts.find((script) => script.id === scriptId);
37253743 if (!existingScript) {
37263744 return AsyncResult.failure(Cause.fail(new Error("Script not found.")));
37273745 }
37283746
37293747 const updatedScript = buildProjectScript(existingScript.id, input);
3730 const nextScripts = activeProject.scripts.map((script) =>
3748 const nextScripts = activeProjectScripts.map((script) =>
37313749 script.id === scriptId
37323750 ? updatedScript
37333751 : input.runOnWorktreeCreate
......@@ -3738,27 +3756,27 @@ export default function ChatView(props: ChatViewProps) {
37383756 return persistProjectScripts({
37393757 projectId: activeProject.id,
37403758 projectCwd: activeProject.workspaceRoot,
3741 previousScripts: activeProject.scripts,
3759 previousScripts: activeProjectScripts,
37423760 nextScripts,
37433761 keybinding: input.keybinding,
37443762 keybindingCommand: commandForProjectScript(scriptId),
37453763 });
37463764 },
3747 [activeProject, persistProjectScripts],
3765 [activeProject, activeProjectScripts, persistProjectScripts],
37483766 );
37493767 const deleteProjectScript = useCallback(
37503768 async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => {
37513769 if (!activeProject) {
37523770 return AsyncResult.success(undefined);
37533771 }
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
37583776 const result = await persistProjectScripts({
37593777 projectId: activeProject.id,
37603778 projectCwd: activeProject.workspaceRoot,
3761 previousScripts: activeProject.scripts,
3779 previousScripts: activeProjectScripts,
37623780 nextScripts,
37633781 keybinding: null,
37643782 keybindingCommand: commandForProjectScript(scriptId),
......@@ -3780,7 +3798,7 @@ export default function ChatView(props: ChatViewProps) {
37803798 }
37813799 return result;
37823800 },
3783 [activeProject, persistProjectScripts],
3801 [activeProject, activeProjectScripts, persistProjectScripts],
37843802 );
37853803
37863804 const handleRuntimeModeChange = useCallback(
......@@ -6061,7 +6079,7 @@ export default function ChatView(props: ChatViewProps) {
60616079
60626080 const scriptId = projectScriptIdFromCommand(command);
60636081 if (!scriptId || !activeProject) return;
6064 const script = activeProject.scripts.find((entry) => entry.id === scriptId);
6082 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
60656083 if (!script) return;
60666084 event.preventDefault();
60676085 event.stopPropagation();
......@@ -6072,6 +6090,7 @@ export default function ChatView(props: ChatViewProps) {
60726090 }, [
60736091 activeProject,
60746092 activeRightPanelSurface,
6093 activeProjectScripts,
60756094 addTerminalSurface,
60766095 activeThreadRef,
60776096 activeThreadPinned,
......@@ -6747,7 +6766,7 @@ export default function ChatView(props: ChatViewProps) {
67476766 const title = truncate(titleSeed);
67486767 const threadCreateModelSelection = createModelSelection(
67496768 ctxSelectedModelSelection.instanceId,
6750 ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL,
6769 ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL,
67516770 ctxSelectedModelSelection.options,
67526771 );
67536772
......@@ -7885,7 +7904,7 @@ export default function ChatView(props: ChatViewProps) {
78857904 activeProjectFaviconPath={activeProject?.faviconPath ?? null}
78867905 activeProjectIcon={activeProject?.projectIcon ?? null}
78877906 openInCwd={gitCwd}
7888 activeProjectScripts={activeProject?.scripts}
7907 activeProjectScripts={activeProjectScripts}
78897908 preferredScriptId={
78907909 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
78917910 }
......@@ -8124,9 +8143,7 @@ export default function ChatView(props: ChatViewProps) {
81248143 interactionMode={interactionMode}
81258144 lockedProvider={lockedProvider}
81268145 providerStatuses={providerStatuses as ServerProvider[]}
8127 activeProjectDefaultModelSelection={
8128 activeProject?.defaultModelSelection
8129 }
8146 activeProjectDefaultModelSelection={activeProjectDefaultModelSelection}
81308147 activeThreadModelSelection={activeThread?.modelSelection}
81318148 activeContextWindow={activeContextWindow}
81328149 compactThreadUnavailable={compactThreadUnavailable}
apps/web/src/components/CommandPalette.tsx+2
......@@ -1791,6 +1791,8 @@ function OpenCommandPaletteDialog(props: {
17911791 run: async () => {
17921792 await navigate({
17931793 to: item.to,
1794 search: (previous) =>
1795 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
17941796 hash: item.targetId ?? item.id,
17951797 replace: pathname === item.to,
17961798 hashScrollIntoView: false,
apps/web/src/components/chat/DraftHeroHeadline.tsx+7-2
......@@ -149,8 +149,13 @@ export function DraftHeroHeadline({
149149 );
150150 if (!hasExplicitComposerModelSelection(currentDraft)) {
151151 applyStickyState(draftId);
152 if (project.defaultModelSelection) {
153 setModelSelection(draftId, project.defaultModelSelection, {
152 const defaultModelSelection =
153 project.defaultModelSelection ??
154 environments.find(
155 (environment) => environment.environmentId === project.environmentId,
156 )?.serverConfig?.settings.defaultModelSelection;
157 if (defaultModelSelection) {
158 setModelSelection(draftId, defaultModelSelection, {
154159 replaceOptions: true,
155160 });
156161 }
apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx+1-6
......@@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation";
66import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
77import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths";
88import { newProjectId } from "../../lib/utils";
9import { resolveDefaultProviderModelSelection } from "../../providerInstances";
109import { readProjects, waitForProject } from "../../state/entities";
1110import { usePrimaryEnvironment } from "../../state/environments";
1211import { projectEnvironment } from "../../state/projects";
......@@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() {
5251 ) ?? null,
5352 createProject: async (environmentId, workspaceRoot) => {
5453 const projectId = newProjectId();
55 const providers =
56 primaryEnvironment?.environmentId === environmentId
57 ? (primaryEnvironment.serverConfig?.providers ?? [])
58 : [];
5954 const result = await createProject({
6055 environmentId,
6156 input: {
......@@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() {
6358 title: inferProjectTitleFromPath(workspaceRoot),
6459 workspaceRoot,
6560 createWorkspaceRootIfMissing: false,
66 defaultModelSelection: resolveDefaultProviderModelSelection(providers, null),
61 defaultModelSelection: null,
6762 },
6863 });
6964 if (result._tag === "Failure") {
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-6
......@@ -51,7 +51,6 @@ import {
5151} from "../../onboarding/targetEnvironment.logic";
5252import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
5353import { newProjectId, randomUUID } from "../../lib/utils";
54import { resolveDefaultProviderModelSelection } from "../../providerInstances";
5554import { agentSessionImport, agentSessionScan } from "../../state/agentSessions";
5655import { readProjects, useProjects } from "../../state/entities";
5756import { useEnvironments, usePrimaryEnvironment } from "../../state/environments";
......@@ -1029,9 +1028,6 @@ function ImportStep({
10291028 const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId);
10301029 const environmentId = targetEnvironment?.environmentId ?? null;
10311030 const machineLabel = targetEnvironment?.label ?? "this machine";
1032 const providers = useAtomValue(
1033 serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)),
1034 );
10351031 const scan = useEnvironmentQuery(
10361032 environmentId === null ? null : agentSessionScan({ environmentId, input: {} }),
10371033 );
......@@ -1123,7 +1119,6 @@ function ImportStep({
11231119 const importGeneration = importGenerationRef.current;
11241120 const importedProjects = importedProjectsRef.current;
11251121 const projectAttempts = projectAttemptsRef.current;
1126 const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null);
11271122 // Interrupted imports are neither failures nor successes — the command was
11281123 // superseded or the environment dropped — but they still didn't land, so
11291124 // they must not read as "imported everything". Retries skip paths that
......@@ -1164,7 +1159,7 @@ function ImportStep({
11641159 title: candidate.title,
11651160 workspaceRoot: candidate.path,
11661161 createWorkspaceRootIfMissing: false,
1167 defaultModelSelection,
1162 defaultModelSelection: null,
11681163 },
11691164 });
11701165 if (
apps/web/src/components/settings/IntegrationsSettings.test.tsx+13-1
......@@ -1,4 +1,10 @@
11import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRouter,
6 RouterProvider,
7} from "@tanstack/react-router";
28import { act, StrictMode, type ReactNode } from "react";
39import { create, type ReactTestRenderer } from "react-test-renderer";
410import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
......@@ -44,13 +50,19 @@ afterEach(async () => {
4450});
4551
4652async function openSettings() {
53 const router = createRouter({
54 routeTree: createRootRoute({ component: IntegrationsSettingsPanel }),
55 history: createMemoryHistory(),
56 });
57 await router.load();
4758 await act(() => {
4859 renderer = create(
4960 <StrictMode>
50 <IntegrationsSettingsPanel />
61 <RouterProvider router={router} />
5162 </StrictMode>,
5263 );
5364 });
65 expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined();
5466}
5567
5668describe("Integrations browser discovery", () => {
apps/web/src/components/settings/IntegrationsSettings.tsx+10-30
......@@ -20,7 +20,6 @@ import {
2020 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
2121 DEFAULT_BROWSER_VIEWPORT,
2222 DEFAULT_PREVIEW_APPEARANCE,
23 DEFAULT_UNIFIED_SETTINGS,
2423 DEFAULT_PREVIEW_ZOOM_FACTOR,
2524 FILL_PREVIEW_VIEWPORT,
2625 PREVIEW_VIEWPORT_MAX_AREA,
......@@ -35,6 +34,7 @@ import {
3534 type PreviewViewportSetting,
3635} from "@t3tools/contracts";
3736import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport";
37import { Link } from "@tanstack/react-router";
3838import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react";
3939import { useCallback, useRef, useState, type ReactNode } from "react";
4040
......@@ -86,7 +86,6 @@ import {
8686 persistClientSettingsUpdate,
8787 useClientSettings,
8888 useClientSettingsHydrated,
89 usePrimarySettings,
9089 useUpdatePrimarySettings,
9190} from "~/hooks/useSettings";
9291
......@@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean })
552551}
553552
554553function AgentBrowserAccessSetting() {
555 const settings = usePrimarySettings();
556 const updateSettings = useUpdatePrimarySettings();
557
558554 return (
559555 <SettingsRow
560 serverScoped
561556 {...searchableSetting("agent-browser-access")}
562 description="Allow agents to use the preview browser. Off hides browser tools from agents, not you."
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 }
557 description="Choose whether agents can use the preview browser for all projects or a specific project."
580558 control={
581 <Switch
582 checked={settings.enableAgentBrowserAccess}
583 onCheckedChange={(checked) =>
584 updateSettings({ enableAgentBrowserAccess: Boolean(checked) })
559 <Button
560 render={
561 <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} />
585562 }
586 aria-label="Allow agent browser access"
587 />
563 size="sm"
564 variant="outline"
565 >
566 Project settings
567 </Button>
588568 }
589569 />
590570 );
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 {
1212 deriveProjectGroupingOverrideKey,
1313 selectProjectGroupingSettings,
1414} from "../../logicalProject";
15import type {
16 ContextMenuItem,
17 ModelSelection,
18 ProjectIconOverride,
19 ProviderDriverKind,
20 SidebarProjectGroupingMode,
21 T3ProjectFileScript,
22 ThreadEnvMode,
15import {
16 type EnvironmentId,
17 type ModelSelection,
18 type ProjectIconOverride,
19 type ProjectId,
20 type ProjectScript,
21 type ResolvedKeybindingsConfig,
22 type ServerSettings,
23 type ProviderDriverKind,
24 type SidebarProjectGroupingMode,
25 type T3ProjectFileScript,
26 type ThreadEnvMode,
2327} from "@t3tools/contracts";
2428import { resolveEnvModeLabel } from "../BranchToolbar.logic";
2529import { createModelSelection } from "@t3tools/shared/model";
30import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings";
31import {
32 projectScriptsInheritDefaults,
33 resolveProjectScripts,
34} from "@t3tools/shared/projectScripts";
2635import { 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";
2838import * as Cause from "effect/Cause";
29import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
30import {
31 lazy,
32 Suspense,
33 useCallback,
34 useEffect,
35 useMemo,
36 useRef,
37 useState,
38 type MouseEvent as ReactMouseEvent,
39} from "react";
39import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react";
40import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
4041
4142import { useComposerDraftStore } from "../../composerDraftStore";
42import { isElectron } from "../../env";
4343import {
4444 useClientSettings,
4545 useEnvironmentSettings,
4646 useUpdateClientSettings,
47 usePrimarySettings,
4847} from "../../hooks/useSettings";
49import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
5048import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";
51import { shortcutLabelForCommand } from "../../keybindings";
52import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";
53import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
54import { readLocalApi } from "../../localApi";
49import { ProjectActionsList } from "./ProjectActionsList";
50import { isElectron } from "../../env";
51import {
52 decodeProjectScriptKeybindingRule,
53 keybindingValueForCommand,
54} from "../../lib/projectScriptKeybindings";
5555import {
5656 buildProjectScript,
5757 commandForProjectScript,
5858 nextProjectScriptId,
5959} from "../../projectScripts";
60import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";
60import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";
61import { readLocalApi } from "../../localApi";
6162import {
6263 applyProviderInstanceSettings,
6364 deriveProviderInstanceEntries,
......@@ -98,16 +99,8 @@ import {
9899 MenuTrigger,
99100} from "../ui/menu";
100101import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
101import { SidebarInset } from "../ui/sidebar";
102102import { Switch } from "../ui/switch";
103103import { 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";
111104import {
112105 SETTINGS_PICKER_TRIGGER_CLASSNAME,
113106 SettingResetButton,
......@@ -127,14 +120,14 @@ const ProjectIconPickerDialog = lazy(() =>
127120 })),
128121);
129122
130const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
123export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
131124 repository: "Group by repository",
132125 repository_path: "Group by repository path",
133126 separate: "Keep separate",
134127};
135128
136129/** Logical project groups for the settings page, sorted by display name. */
137function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
130export function useSettingsProjectGroups(): SidebarProjectSnapshot[] {
138131 const projects = useProjects();
139132 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
140133 const primaryEnvironmentId = usePrimaryEnvironmentId();
......@@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string {
162155 return `${member.environmentId}:${member.id}`;
163156}
164157
165export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
166 const navigate = useNavigate();
167 const canGoBack = useCanGoBack();
168 const navigateBackWithinApp = useCallback(() => {
169 if (canGoBack) {
170 window.history.back();
171 return;
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 }) {
158export function ProjectSettingsPanel({
159 projectKey,
160 environmentId = null,
161}: {
162 projectKey: string;
163 environmentId?: EnvironmentId | null;
164}) {
257165 const groups = useSettingsProjectGroups();
258166 const navigate = useNavigate();
259167
260168 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
262177 // Remember the members of the last rendered group so a grouping-rule change
263178 // (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);
265184 useEffect(() => {
266 if (!selected) return;
185 if (!selected || members.length === 0) return;
267186 lastSelectionRef.current = {
268187 key: selected.projectKey,
269 memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey),
188 environmentId,
189 memberKeys: members.map((member) => member.physicalProjectKey),
270190 };
271 }, [selected]);
191 }, [selected, members, environmentId]);
272192
273193 // A grouping-rule change replaces the group key mid-visit; follow the
274194 // project to its new key instead of parking on the not-found state.
275195 useEffect(() => {
276 if (selected !== null) return;
196 if (members.length > 0) return;
277197 const last = lastSelectionRef.current;
278 if (last?.key !== projectKey) return;
198 if (last?.key !== projectKey || last.environmentId !== environmentId) return;
279199 const successor = groups.find((group) =>
280200 group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)),
281201 );
282202 if (successor) {
283203 void navigate({
284 to: "/projects/$projectKey",
285 params: { projectKey: successor.projectKey },
204 to: "/settings/projects",
205 search: { project: successor.projectKey, machine: environmentId ?? undefined },
286206 replace: true,
287207 hashScrollIntoView: false,
288208 });
289209 }
290 }, [groups, navigate, projectKey, selected]);
210 }, [groups, navigate, projectKey, members.length, environmentId]);
291211
292212 if (!selected) {
293213 return (
......@@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
298218 </div>
299219 );
300220 }
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 };
302380}
303381
304function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
382function ProjectDetail({
383 group,
384 hasOtherMembers,
385}: {
386 group: SidebarProjectSnapshot;
387 hasOtherMembers: boolean;
388}) {
305389 const navigate = useNavigate();
306390 const primaryEnvironmentId = usePrimaryEnvironmentId();
391 const { environments } = useEnvironments();
392 const environmentById = useMemo(
393 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
394 [environments],
395 );
307396 const representative =
308397 group.memberProjects.find(
309 (member) => member.environmentId === group.environmentId && member.id === group.id,
398 (member) => environmentById.get(member.environmentId)?.serverConfig != null,
310399 ) ?? group.memberProjects[0]!;
311 const settings = usePrimarySettings();
312400 // Provider instances and model options belong to the environment that runs
313401 // the project's threads. The hosted app has no primary environment, so
314402 // reading them from there would show "No providers available" everywhere.
......@@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
320408 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
321409 const threads = useThreadShells();
322410 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
323 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
324 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
325 reportFailure: false,
326 });
327 const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {
328 reportFailure: false,
411 const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting");
412 const [savingBrowserAccess, setSavingBrowserAccess] = useState(false);
413 const savingBrowserAccessRef = useRef(false);
414 const browserOverrides = group.memberProjects.map(
415 (member) =>
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 );
329428 });
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 });
330482 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
346484 const faviconPath = representative.faviconPath ?? null;
347485 const projectIcon = representative.projectIcon ?? null;
......@@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
355493 ? window.desktopBridge?.pickProjectFavicon
356494 : 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]);
366496 const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => {
367497 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
368498 const error = squashAtomCommandFailure(result);
......@@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
437567
438568 // ----- default model -----
439569 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 });
441589 const resolvedInstanceId = resolvedSelection?.instanceId ?? null;
442590 const resolvedModel = resolvedSelection?.model ?? null;
443591 const instanceEntries = useMemo(
......@@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
461609 [resolvedInstanceId, resolvedModel, serverProviders, projectSettings],
462610 );
463611 const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);
464 const setDefaultModel = useCallback(
465 (selection: ModelSelection | null) =>
466 void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"),
467 [updateAllMembers],
468 );
612 const setDefaultModel = (selection: ModelSelection | null) => {
613 if (selection !== null) {
614 for (const member of group.memberProjects) {
615 const environment = environmentById.get(member.environmentId);
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
470646 // ----- new-thread workspace mode -----
471647 const storedEnvMode = representative.defaultThreadEnvMode ?? null;
648 const mixedWorkspace = group.memberProjects.some(
649 (member) => member.defaultThreadEnvMode !== storedEnvMode,
650 );
472651 const setDefaultThreadEnvMode = useCallback(
473652 (mode: ThreadEnvMode | null) =>
474653 void updateAllMembers(
......@@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
478657 [updateAllMembers],
479658 );
480659
481 const autoPull = representative.autoPull ?? false;
482 const setAutoPull = useCallback(
483 (enabled: boolean) =>
484 void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"),
485 [updateAllMembers],
660 const autoPull = resolveProjectAutoPull(
661 projectSettings,
662 representative.id,
663 representative.autoPull,
486664 );
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
488679 // ----- project icon -----
489680 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
......@@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
506697 );
507698
508699 // ----- checkout selection and scripts -----
509 const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey);
510 const selectedCheckout =
511 group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ??
512 representative;
700 const hasMultipleCheckouts = group.memberProjects.length > 1;
701 const [selectedCheckoutKey, setSelectedCheckoutKey] = useState<string | null>(null);
702 const selectedCheckoutMatch = group.memberProjects.find(
703 (member) => member.physicalProjectKey === selectedCheckoutKey,
704 );
705 const selectedCheckout = selectedCheckoutMatch ?? representative;
513706 const selectedServerConfig = useAtomValue(
514707 serverEnvironment.configValueAtom(selectedCheckout.environmentId),
515708 );
516709 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);
518713 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
519 // Script writes replace the whole array, so two overlapping writes computed
520 // from the same snapshot would drop each other's changes. One at a time.
521 const [isSavingScripts, setIsSavingScripts] = useState(false);
522 const savingScriptsRef = useRef(false);
714 const {
715 saving: isSavingScripts,
716 persist: persistScripts,
717 submit: submitScript,
718 } = useProjectScriptSettings([
719 {
720 environmentId: selectedCheckout.environmentId,
721 settings: scriptSettings,
722 keybindings,
723 project: selectedCheckout,
724 },
725 ]);
523726 const t3File = useT3ProjectFileState(
524727 selectedCheckout.environmentId,
525728 selectedCheckout.workspaceRoot,
526729 );
527730 // What the "Default" option resolves to while no override is set: the
528731 // 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;
530733 const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global";
531734 const importableScripts = useMemo(
532735 () =>
......@@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
541744 [scripts, t3File.scripts],
542745 );
543746
544 const persistScripts = useCallback(
545 async (
546 nextScripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>,
547 keybinding: string | null | undefined,
548 keybindingCommand: ReturnType<typeof commandForProjectScript>,
549 ): Promise<AtomCommandResult<void, unknown>> => {
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 );
747 const deleteScript = (scriptId: string) =>
748 void persistScripts(
749 (current) => current.filter((script) => script.id !== scriptId),
750 scriptId,
751 null,
752 );
673753
674754 const importFileScript = useCallback(
675755 async (fileScript: T3ProjectFileScript) => {
......@@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
692772 });
693773 }
694774 },
695 [submitScript],
775 [submitScript, setEditorRequest],
696776 );
697777
698778 // ----- checkouts -----
......@@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
720800 memberKeys.has(`${thread.environmentId}:${thread.projectId}`),
721801 );
722802 const isWholeGroup = members.length === group.memberProjects.length;
803 const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project";
723804 const singleMember = members.length === 1 ? members[0]! : null;
724805 const targetLabel = singleMember?.title ?? group.displayName;
725806 const confirmed = await settlePromise(() =>
726807 api.dialogs.confirm(
727808 [
728809 projectThreads.length > 0
729 ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`
730 : `Remove project "${targetLabel}"?`,
810 ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`
811 : `Remove ${targetKind} "${targetLabel}"?`,
731812 ...(singleMember
732813 ? [
733814 `Path: ${singleMember.workspaceRoot}`,
......@@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
741822 "This permanently clears conversation history for those threads and any archived threads.",
742823 ]
743824 : ["This permanently clears any archived conversation history."]),
744 isWholeGroup
825 isWholeGroup && !hasOtherMembers
745826 ? "This removes only the project entries, not the files on disk."
746827 : "Other entries in this grouped project are unaffected.",
747828 "This action cannot be undone.",
......@@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
783864 draftStore.clearProjectDraftThreadId(projectRef);
784865 }
785866
786 // The project's settings page just deleted itself; there is no projects
787 // listing to fall back to, so leave settings entirely.
788867 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 }
790877 }
791878 },
792879 [
793880 deleteProject,
794881 group.displayName,
795882 group.memberProjects.length,
883 group.projectKey,
884 hasOtherMembers,
796885 navigate,
797886 reportFailure,
798887 threads,
799888 ],
800889 );
801890
802 const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0;
803891 const selectedCheckoutGrouping =
804892 projectGroupingSettings.sidebarProjectGroupingOverrides?.[
805893 deriveProjectGroupingOverrideKey(selectedCheckout)
806894 ] ?? "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
809907 return (
810908 <>
811 <SettingsPageContainer width="wide" className="gap-8">
812 <SettingsSection title="Project">
909 <SettingsPageContainer className="gap-6">
910 <SettingsSection title="Project" hideTitle>
813911 <SettingsRow
814912 title="Name"
815913 description="The shared name for this project group in the sidebar and thread lists."
......@@ -887,11 +985,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
887985 />
888986 <SettingsRow
889987 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 }
8911000 resetAction={
892 storedSelection !== null ? (
1001 group.memberProjects.some((member) => member.defaultModelSelection !== null) ? (
8931002 <SettingResetButton
8941003 label="project default model"
1004 tooltip="Reset to inherited model"
8951005 onClick={() => setDefaultModel(null)}
8961006 />
8971007 ) : null
......@@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
9461056 />
9471057 <SettingsRow
9481058 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 }
9501071 resetAction={
951 storedEnvMode !== null ? (
1072 group.memberProjects.some((member) => member.defaultThreadEnvMode !== null) ? (
9521073 <SettingResetButton
9531074 label="project workspace default"
1075 tooltip="Reset to inherited workspace"
9541076 onClick={() => setDefaultThreadEnvMode(null)}
9551077 />
9561078 ) : null
......@@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
9901112 <SettingsRow
9911113 title="Automatically pull"
9921114 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 }
9931122 resetAction={
994 autoPull ? (
995 <SettingResetButton label="automatic pull" onClick={() => setAutoPull(false)} />
1123 autoPullOverridden ? (
1124 <SettingResetButton
1125 label="automatic pull"
1126 tooltip="Reset to inherited automatic pull setting"
1127 disabled={savingBrowserAccess}
1128 onClick={() => void setAutoPull(undefined)}
1129 />
9961130 ) : null
9971131 }
9981132 control={
9991133 <Switch
10001134 checked={autoPull}
1135 disabled={savingBrowserAccess}
10011136 aria-label="Automatically pull the default branch"
1002 onCheckedChange={setAutoPull}
1137 onCheckedChange={(enabled) => void setAutoPull(enabled)}
10031138 />
10041139 }
10051140 />
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 />
10061197 </SettingsSection>
10071198
1008 <SettingsSection
1009 title="Checkout"
1010 headerAction={
1011 <Select
1012 value={selectedCheckout.physicalProjectKey}
1013 onValueChange={(value) => setSelectedCheckoutKey(String(value))}
1014 >
1015 <SelectTrigger size="sm" className="max-w-64" aria-label="Selected checkout">
1016 <SelectValue>{selectedCheckoutLabel}</SelectValue>
1017 </SelectTrigger>
1018 <SelectPopup align="end" alignItemWithTrigger={false}>
1019 {group.memberProjects.map((member) => (
1020 <SelectItem
1021 key={member.physicalProjectKey}
1022 hideIndicator
1023 value={member.physicalProjectKey}
1024 >
1025 {member.environmentLabel ?? "This machine"} · {member.workspaceRoot}
1026 </SelectItem>
1027 ))}
1028 </SelectPopup>
1029 </Select>
1030 }
1031 >
1032 <div className="px-3 py-2 sm:px-4">
1033 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">
1034 <Tooltip>
1035 <TooltipTrigger
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>
1199 <SettingsSection title="Checkout">
1200 {hasMultipleCheckouts ? (
1201 <SettingsRow
1202 title="Checkout"
1203 description="Actions and grouping belong to this checkout."
1204 control={
1205 <Select
1206 value={selectedCheckout.physicalProjectKey}
1207 onValueChange={(value) => {
1208 if (value) setSelectedCheckoutKey(value);
1209 }}
1210 >
1211 <SelectTrigger size="sm" aria-label="Checkout">
1212 <SelectValue className="max-w-96 truncate">{selectedCheckoutLabel}</SelectValue>
1213 </SelectTrigger>
1214 <SelectPopup align="end" alignItemWithTrigger={false}>
1215 {group.memberProjects.map((member) => (
1216 <SelectItem key={member.physicalProjectKey} value={member.physicalProjectKey}>
1217 <span className="max-w-96 whitespace-normal break-all">
1218 {checkoutLabel(member)}
1219 </span>
1220 </SelectItem>
1221 ))}
1222 </SelectPopup>
1223 </Select>
1224 }
1225 />
1226 ) : null}
10631227 <SettingsRow
10641228 title="Project grouping"
10651229 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 }
10661239 control={
10671240 <Select
10681241 value={selectedCheckoutGrouping}
......@@ -1121,10 +1294,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
11211294 <div className="min-w-0">
11221295 <h3 className="text-base font-semibold text-foreground">Actions</h3>
11231296 <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}.`}
11251300 </p>
11261301 </div>
11271302 <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}
11281311 {importableScripts.length > 0 ? (
11291312 <Menu>
11301313 <MenuTrigger
......@@ -1173,65 +1356,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
11731356 </Button>
11741357 </div>
11751358 </div>
1176 {scripts.length === 0 ? (
1177 <p className="px-3 py-2 text-base text-muted-foreground sm:px-4 sm:text-sm">
1178 No actions configured for this checkout.
1179 </p>
1180 ) : (
1181 scripts.map((script) => {
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 )}
1359 <ProjectActionsList
1360 scripts={scripts}
1361 keybindings={keybindings}
1362 disabled={isSavingScripts}
1363 onEdit={(script) => setEditorRequest(editorRequestForScript(script, keybindings))}
1364 />
12351365 {t3File.status === "invalid" ? (
12361366 <SettingsRow
12371367 title="t3.json is invalid"
......@@ -1244,12 +1374,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
12441374 <SettingsSection title="Danger">
12451375 <SettingsRow
12461376 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"
12481382 }
12491383 description={
1250 group.memberProjects.length > 1
1251 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.`
1252 : "Deletes the project entry and its threads. Files on disk are not touched."
1384 hasOtherMembers
1385 ? "Deletes the selected machine's checkout entries and their threads. Other machines and 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."
12531389 }
12541390 control={
12551391 <Button
......@@ -1258,7 +1394,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
12581394 onClick={() => void removeMembers(group.memberProjects)}
12591395 >
12601396 <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"}
12621402 </Button>
12631403 }
12641404 />
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() {
25572557
25582558 <SettingsSection id="projects-and-threads" title="Projects & threads">
25592559 <SettingsRow
2560 serverScoped
25612560 {...searchableSetting("new-threads")}
2562 description="Pick the default workspace mode for newly created draft threads."
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 }
2561 description="Choose the default model and workspace for all projects or a specific project."
25792562 control={
2580 <Select
2581 value={settings.defaultThreadEnvMode}
2582 onValueChange={(value) => {
2583 if (value === "local" || value === "worktree") {
2584 updateSettings({ defaultThreadEnvMode: value });
2585 }
2586 }}
2563 <Button
2564 render={
2565 <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} />
2566 }
2567 size="sm"
2568 variant="outline"
25872569 >
2588 <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default thread mode">
2589 <SelectValue>
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>
2570 Project settings
2571 </Button>
26022572 }
26032573 />
26042574
26052575 <SettingsRow
26062576 serverScoped
2607 className="bg-muted/20 sm:pl-9"
2608 title={searchableSetting("start-from-origin").title}
2577 {...searchableSetting("start-from-origin")}
26092578 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
26102579 resetAction={
26112580 settings.newWorktreesStartFromOrigin !==
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-1
......@@ -15,6 +15,7 @@ import {
1515 BlocksIcon,
1616 BotIcon,
1717 GitBranchIcon,
18 PanelsTopLeftIcon,
1819 KeyboardIcon,
1920 Link2Icon,
2021 PaletteIcon,
......@@ -72,6 +73,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
7273> = {
7374 "/settings/general": Settings2Icon,
7475 "/settings/appearance": PaletteIcon,
76 "/settings/projects": PanelsTopLeftIcon,
7577 "/settings/keybindings": KeyboardIcon,
7678 "/settings/providers": BotIcon,
7779 "/settings/integrations": BlocksIcon,
......@@ -274,12 +276,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
274276 setOpenMobile(false);
275277 }
276278 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 ) {
278284 scrollToSettingsTarget(targetId);
279285 return;
280286 }
281287 void navigate({
282288 to: item.to,
289 search: (previous) =>
290 item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,
283291 hash: targetId,
284292 replace: true,
285293 hashScrollIntoView: false,
apps/web/src/components/settings/settingsLayout.tsx+8-2
......@@ -284,7 +284,11 @@ export function SettingsRow({
284284 ref={targetRef}
285285 tabIndex={rowProps.id ? -1 : rowProps.tabIndex}
286286 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 )}
288292 >
289293 <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8">
290294 <div className="min-w-0 flex-1 space-y-1">
......@@ -320,10 +324,12 @@ export function SettingsRow({
320324
321325export function SettingResetButton({
322326 label,
327 tooltip = "Reset to default",
323328 disabled = false,
324329 onClick,
325330}: {
326331 label: string;
332 tooltip?: string;
327333 disabled?: boolean;
328334 onClick: () => void;
329335}) {
......@@ -345,7 +351,7 @@ export function SettingResetButton({
345351 </Button>
346352 }
347353 />
348 <TooltipPopup side="top">Reset to default</TooltipPopup>
354 <TooltipPopup side="top">{tooltip}</TooltipPopup>
349355 </Tooltip>
350356 );
351357}
apps/web/src/components/settings/settingsSearch.ts+12-3
......@@ -2,6 +2,7 @@ import { isElectron } from "~/env";
22import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
33
44export type SettingsPath =
5 | "/settings/projects"
56 | "/settings/general"
67 | "/settings/appearance"
78 | "/settings/keybindings"
......@@ -49,6 +50,7 @@ export interface SettingsSearchAvailability {
4950export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
5051 "/settings/general": "General",
5152 "/settings/appearance": "Appearance",
53 "/settings/projects": "Projects",
5254 "/settings/keybindings": "Keybindings",
5355 "/settings/providers": "Providers",
5456 "/settings/integrations": "Integrations",
......@@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
6365 * that may not be mounted point at their nearest stable section instead.
6466 */
6567export 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 },
6676 {
6777 id: "color-scheme",
6878 title: "Color scheme",
......@@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [
235245 {
236246 id: "new-threads",
237247 title: "New threads",
238 to: "/settings/general",
248 to: "/settings/projects",
239249 searchTerms: ["default workspace mode draft local worktree"],
240250 },
241251 {
242252 id: "start-from-origin",
243253 title: "Start from origin",
244254 to: "/settings/general",
245 targetId: "new-threads",
246255 searchTerms: ["new worktrees latest matching remote branch local"],
247256 },
248257 {
......@@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [
345354 {
346355 id: "agent-browser-access",
347356 title: "Agent browser access",
348 to: "/settings/integrations",
357 to: "/settings/projects",
349358 searchTerms: ["allow open drive preview tools sessions"],
350359 },
351360 {
apps/web/src/hooks/useHandleNewThread.test.ts+23-3
......@@ -49,14 +49,31 @@ const testState = vi.hoisted(() => {
4949});
5050
5151vi.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 ]),
5367}));
5468vi.mock("@t3tools/client-runtime/environment", () => ({
5569 scopedProjectKey: () => "remote-project",
5670 scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
5771 scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
5872}));
59vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" }));
73vi.mock("@t3tools/contracts", () => ({
74 DEFAULT_RUNTIME_MODE: "default",
75 DEFAULT_SERVER_SETTINGS: {},
76}));
6077vi.mock("@t3tools/shared/threadEnvMode", () => ({
6178 resolveDefaultThreadEnvMode: (input: {
6279 readonly projectFile: "local" | "worktree" | null;
......@@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({
113130 useProjects: () => [],
114131 useThread: () => null,
115132}));
116vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} }));
133vi.mock("../state/server", () => ({
134 environmentServerConfigsAtom: {},
135 primaryServerSettingsAtom: "primary-settings",
136}));
117137vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));
118138vi.mock("../uiStateStore", () => ({
119139 legacyProjectCwdPreferenceKey: () => "remote-project",
apps/web/src/hooks/useHandleNewThread.ts+20-10
......@@ -4,7 +4,12 @@ import {
44 scopeProjectRef,
55 scopeThreadRef,
66} 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";
813import { useParams, useRouter } from "@tanstack/react-router";
914import { useCallback, useMemo } from "react";
1015import {
......@@ -30,7 +35,7 @@ import {
3035 resolveNewThreadModelSelectionOverride,
3136} from "../lib/chatThreadActions";
3237import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults";
33import { primaryServerSettingsAtom } from "../state/server";
38import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server";
3439import { resolveThreadRouteTarget } from "../threadRoutes";
3540import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
3641import { useClientSettings } from "./useSettings";
......@@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
5560}
5661
5762export function useNewThreadHandler() {
58 // New-thread defaults are a user preference, and the settings UI only ever
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 environmentServerConfigs = useAtomValue(environmentServerConfigsAtom);
6364 const primaryServerSettings = useAtomValue(primaryServerSettingsAtom);
6465 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
6566 const router = useRouter();
......@@ -83,6 +84,8 @@ export function useNewThreadHandler() {
8384 // up again and finding whichever draft it happens to hold.
8485 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
8586 const projects = readProjects();
87 const targetServerSettings =
88 environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS;
8689 const {
8790 getComposerDraft,
8891 getDraftSessionByLogicalProjectKey,
......@@ -138,7 +141,8 @@ export function useNewThreadHandler() {
138141 );
139142 const resolveModelSelectionOverride = (destinationDraftId: DraftId) =>
140143 resolveNewThreadModelSelectionOverride({
141 projectDefaultSelection: project?.defaultModelSelection ?? null,
144 projectDefaultSelection:
145 project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null,
142146 carrySelection: carryModelSelection,
143147 carrySourceDraftId:
144148 currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null,
......@@ -157,7 +161,7 @@ export function useNewThreadHandler() {
157161 project.workspaceRoot,
158162 )
159163 : null,
160 globalDefault: primaryServerSettings.defaultThreadEnvMode,
164 globalDefault: targetServerSettings.defaultThreadEnvMode,
161165 });
162166 };
163167 const logicalProjectKey = project
......@@ -429,7 +433,13 @@ export function useNewThreadHandler() {
429433 return { draftId, threadId };
430434 })();
431435 },
432 [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router],
436 [
437 environmentServerConfigs,
438 getCurrentRouteTarget,
439 primaryServerSettings.newWorktreesStartFromOrigin,
440 projectGroupingSettings,
441 router,
442 ],
433443 );
434444}
435445
apps/web/src/routeTree.gen.ts+21
......@@ -18,6 +18,7 @@ import { Route as ChatRouteImport } from './routes/_chat'
1818import { Route as ChatIndexRouteImport } from './routes/_chat.index'
1919import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
2020import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
21import { Route as SettingsProjectsRouteImport } from './routes/settings.projects'
2122import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings'
2223import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations'
2324import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
......@@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({
7576 path: '/providers',
7677 getParentRoute: () => SettingsRoute,
7778} as any)
79const SettingsProjectsRoute = SettingsProjectsRouteImport.update({
80 id: '/projects',
81 path: '/projects',
82 getParentRoute: () => SettingsRoute,
83} as any)
7884const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({
7985 id: '/keybindings',
8086 path: '/keybindings',
......@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
154160 '/settings/general': typeof SettingsGeneralRoute
155161 '/settings/integrations': typeof SettingsIntegrationsRoute
156162 '/settings/keybindings': typeof SettingsKeybindingsRoute
163 '/settings/projects': typeof SettingsProjectsRoute
157164 '/settings/providers': typeof SettingsProvidersRoute
158165 '/settings/source-control': typeof SettingsSourceControlRoute
159166 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
......@@ -175,6 +182,7 @@ export interface FileRoutesByTo {
175182 '/settings/general': typeof SettingsGeneralRoute
176183 '/settings/integrations': typeof SettingsIntegrationsRoute
177184 '/settings/keybindings': typeof SettingsKeybindingsRoute
185 '/settings/projects': typeof SettingsProjectsRoute
178186 '/settings/providers': typeof SettingsProvidersRoute
179187 '/settings/source-control': typeof SettingsSourceControlRoute
180188 '/': typeof ChatIndexRoute
......@@ -199,6 +207,7 @@ export interface FileRoutesById {
199207 '/settings/general': typeof SettingsGeneralRoute
200208 '/settings/integrations': typeof SettingsIntegrationsRoute
201209 '/settings/keybindings': typeof SettingsKeybindingsRoute
210 '/settings/projects': typeof SettingsProjectsRoute
202211 '/settings/providers': typeof SettingsProvidersRoute
203212 '/settings/source-control': typeof SettingsSourceControlRoute
204213 '/_chat/': typeof ChatIndexRoute
......@@ -224,6 +233,7 @@ export interface FileRouteTypes {
224233 | '/settings/general'
225234 | '/settings/integrations'
226235 | '/settings/keybindings'
236 | '/settings/projects'
227237 | '/settings/providers'
228238 | '/settings/source-control'
229239 | '/$environmentId/$threadId'
......@@ -245,6 +255,7 @@ export interface FileRouteTypes {
245255 | '/settings/general'
246256 | '/settings/integrations'
247257 | '/settings/keybindings'
258 | '/settings/projects'
248259 | '/settings/providers'
249260 | '/settings/source-control'
250261 | '/'
......@@ -268,6 +279,7 @@ export interface FileRouteTypes {
268279 | '/settings/general'
269280 | '/settings/integrations'
270281 | '/settings/keybindings'
282 | '/settings/projects'
271283 | '/settings/providers'
272284 | '/settings/source-control'
273285 | '/_chat/'
......@@ -351,6 +363,13 @@ declare module '@tanstack/react-router' {
351363 preLoaderRoute: typeof SettingsProvidersRouteImport
352364 parentRoute: typeof SettingsRoute
353365 }
366 '/settings/projects': {
367 id: '/settings/projects'
368 path: '/projects'
369 fullPath: '/settings/projects'
370 preLoaderRoute: typeof SettingsProjectsRouteImport
371 parentRoute: typeof SettingsRoute
372 }
354373 '/settings/keybindings': {
355374 id: '/settings/keybindings'
356375 path: '/keybindings'
......@@ -462,6 +481,7 @@ interface SettingsRouteChildren {
462481 SettingsGeneralRoute: typeof SettingsGeneralRoute
463482 SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute
464483 SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute
484 SettingsProjectsRoute: typeof SettingsProjectsRoute
465485 SettingsProvidersRoute: typeof SettingsProvidersRoute
466486 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
467487}
......@@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
474494 SettingsGeneralRoute: SettingsGeneralRoute,
475495 SettingsIntegrationsRoute: SettingsIntegrationsRoute,
476496 SettingsKeybindingsRoute: SettingsKeybindingsRoute,
497 SettingsProjectsRoute: SettingsProjectsRoute,
477498 SettingsProvidersRoute: SettingsProvidersRoute,
478499 SettingsSourceControlRoute: SettingsSourceControlRoute,
479500}
apps/web/src/routes/projects.$projectKey.tsx+6-4
......@@ -1,15 +1,17 @@
11import { createFileRoute, redirect } from "@tanstack/react-router";
22
3import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel";
4
53export const Route = createFileRoute("/projects/$projectKey")({
6 beforeLoad: async ({ context }) => {
4 beforeLoad: async ({ context, params }) => {
75 if (
86 context.authGateState.status !== "authenticated" &&
97 context.authGateState.status !== "hosted-static"
108 ) {
119 throw redirect({ to: "/pair", replace: true });
1210 }
11 throw redirect({
12 to: "/settings/projects",
13 search: { project: params.projectKey, machine: undefined },
14 replace: true,
15 });
1316 },
14 component: () => <ProjectSettingsPage projectKey={Route.useParams().projectKey} />,
1517});
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 @@
11# 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
522## Project icons
623
724Choose 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** to
25to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to
926let T3 Code detect an icon again.
1027
1128## Keep the default branch current
packages/client-runtime/src/state/sharedSettings.test.ts+13-3
......@@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => {
4444 sidebarAutoSettleOnMerge: false,
4545 continueThreadsAfterServerUpdate: true,
4646 enableAgentBrowserAccess: false,
47 defaultThreadEnvMode: "worktree",
48 newWorktreesStartFromOrigin: true,
4749 });
4850 expect(sharedPatch).toEqual({
4951 sidebarAutoSettleAfterDays: 7,
5052 sidebarAutoSettleOnMerge: false,
5153 continueThreadsAfterServerUpdate: true,
54 newWorktreesStartFromOrigin: true,
55 });
56 expect(localPatch).toEqual({
57 enableAgentBrowserAccess: false,
58 defaultThreadEnvMode: "worktree",
5259 });
53 expect(localPatch).toEqual({ enableAgentBrowserAccess: false });
5460 });
5561});
5662
......@@ -60,7 +66,6 @@ describe("pickSharedServerSettings", () => {
6066 Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(),
6167 ).toEqual([
6268 "continueThreadsAfterServerUpdate",
63 "defaultThreadEnvMode",
6469 "newWorktreesStartFromOrigin",
6570 "sidebarAutoSettleAfterDays",
6671 "sidebarAutoSettleOnMerge",
......@@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => {
206211 environmentId: boxId,
207212 label: "Remote Box",
208213 syncEligible: true,
209 settings: { ...primarySettings, enableAgentBrowserAccess: false },
214 settings: {
215 ...primarySettings,
216 enableAgentBrowserAccess: false,
217 defaultThreadEnvMode:
218 primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local",
219 },
210220 },
211221 ],
212222 });
packages/client-runtime/src/state/sharedSettings.ts-1
......@@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [
2424 "continueThreadsAfterServerUpdate",
2525 "sidebarAutoSettleAfterDays",
2626 "sidebarAutoSettleOnMerge",
27 "defaultThreadEnvMode",
2827 "newWorktreesStartFromOrigin",
2928 "sourceControlWritingStyle",
3029] 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";
22import * as Duration from "effect/Duration";
33import * as Schema from "effect/Schema";
44import * 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";
611import { UsageLimitSourceId } from "./usageLimitSourceId.ts";
712import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts";
813import {
......@@ -11,7 +16,7 @@ import {
1116 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
1217 ProviderOptionSelections,
1318} from "./model.ts";
14import { ModelSelection } from "./orchestration.ts";
19import { ModelSelection, ProjectScript } from "./orchestration.ts";
1520import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts";
1621import {
1722 DEFAULT_PREVIEW_APPEARANCE,
......@@ -856,6 +861,22 @@ export const ServerSettings = Schema.Struct({
856861 * between a desktop window and a phone attached to the same server.
857862 */
858863 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 ),
859880 sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe(
860881 Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)),
861882 ),
......@@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({
11161137 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
11171138 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
11181139 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)),
11191152 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
11201153 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
11211154 backgroundActivity: Schema.optionalKey(
packages/shared/src/projectScripts.ts+21-1
......@@ -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
323interface ProjectScriptRuntimeEnvInput {
424 project: {
packages/shared/src/serverSettings.test.ts+168
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
34 ProviderDriverKind,
45 ProviderInstanceId,
56 UsageLimitSourceId,
......@@ -9,14 +10,181 @@ import * as Duration from "effect/Duration";
910import { describe, expect, it } from "vite-plus/test";
1011import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
1112import { createModelSelection } from "./model.ts";
13import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts";
1214import {
1315 applyServerSettingsPatch,
1416 isModelSelectionProviderEnabled,
1517 parsePersistedServerObservabilitySettings,
1618 resolveSourceControlWriterModelSelection,
19 resolveProjectAgentBrowserAccess,
20 resolveProjectAutoPull,
1721} from "./serverSettings.ts";
1822
1923describe("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
20188 it("ignores missing and blank persisted observability URLs", () => {
21189 expect(parsePersistedServerObservabilitySettings("{}")).toEqual({
22190 otlpTracesUrl: undefined,
packages/shared/src/serverSettings.ts+54
......@@ -3,6 +3,7 @@ import {
33 isProviderAvailable,
44 resolveProviderInstanceEnabled,
55 type ModelSelection,
6 type ProjectId,
67 type ProviderDriverKind,
78 type ServerProvider,
89 ServerSettings,
......@@ -23,6 +24,27 @@ import {
2324const ServerSettingsJson = fromLenientJson(ServerSettings);
2425const 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
2648type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]];
2749
2850const getLegacyProviderSettings = (
......@@ -151,6 +173,8 @@ export function applyServerSettingsPatch(
151173 // Merged per entry below; its `null` removals must not reach deepMerge.
152174 usageLimitSources: usageLimitSourcesPatch,
153175 usagePriceOverrides: usagePriceOverridesPatch,
176 projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch,
177 projectAutoPullOverrides: projectAutoPullOverridesPatch,
154178 ...patchForMerge
155179 } = patch;
156180 const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current);
......@@ -207,6 +231,36 @@ export function applyServerSettingsPatch(
207231 ...(patch.providerInstances !== undefined
208232 ? { providerInstances: patch.providerInstances }
209233 : {}),
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 : {}),
210264 ...(usageLimitSourcesPatch !== undefined
211265 ? {
212266 usageLimitSources: mergeSettingsEntries(