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