| 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 | 7 | } from "@react-navigation/native"; |
| 8 | 8 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 9 | 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 | 16 | import { |
| 12 | 17 | requestOlderThreadTurns, |
| 13 | 18 | threadHasOlderTurns, |
| 14 | 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 | 25 | import { Platform, ScrollView, View } from "react-native"; |
| 17 | 26 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 18 | 27 | import { useWorkspaceState } from "../../state/workspace"; |
| ... | ... | @@ -627,7 +636,12 @@ function ThreadRouteContent( |
| 627 | 636 | gitOperationLabel: gitState.gitOperationLabel, |
| 628 | 637 | canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), |
| 629 | 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 | 645 | terminalSessions: terminalMenuSessions, |
| 632 | 646 | showDirectFileControl: layout.usesSplitView, |
| 633 | 647 | onOpenTerminal: handleOpenTerminal, |
| ... | ... | @@ -819,6 +833,7 @@ function ThreadRouteContent( |
| 819 | 833 | <> |
| 820 | 834 | {activeInspectorRenderer ? <InspectorPaneRoleActivation /> : null} |
| 821 | 835 | <NativeStackScreenOptions |
| 836 | optionsVersion={threadGitControlProps.projectScripts} | |
| 822 | 837 | options={{ |
| 823 | 838 | // Android draws its own in-flow header (AndroidScreenHeader below); |
| 824 | 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 | 427 | ); |
| 428 | 428 | const projectDefaultModelSelection = resolveDefaultableModelSelection( |
| 429 | 429 | selectedEnvironmentServerConfig, |
| 430 | selectedProject?.defaultModelSelection ?? null, | |
| 430 | selectedProject?.defaultModelSelection ?? | |
| 431 | selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? | |
| 432 | null, | |
| 431 | 433 | ); |
| 432 | 434 | const storedStickyModelSelection = useStickyComposerModelSelection(); |
| 433 | 435 | const stickyModelSelection = resolveDefaultableModelSelection( |
apps/server/src/project/ProjectSetupScriptRunner.test.ts+61| ... | ... | @@ -6,6 +6,7 @@ import * as Option from "effect/Option"; |
| 6 | 6 | import * as Schema from "effect/Schema"; |
| 7 | 7 | |
| 8 | 8 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 9 | import * as ServerSettings from "../serverSettings.ts"; | |
| 9 | 10 | import * as TerminalManager from "../terminal/Manager.ts"; |
| 10 | 11 | import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; |
| 11 | 12 | |
| ... | ... | @@ -68,13 +69,73 @@ const makeTerminalManagerLayer = ( |
| 68 | 69 | const testLayer = ( |
| 69 | 70 | project: OrchestrationProject, |
| 70 | 71 | terminal: Pick<TerminalManager.TerminalManager["Service"], "open" | "write">, |
| 72 | settings = ServerSettings.layerTest(), | |
| 71 | 73 | ) => |
| 72 | 74 | ProjectSetupScriptRunner.layer.pipe( |
| 73 | 75 | Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), |
| 74 | 76 | Layer.provideMerge(makeTerminalManagerLayer(terminal)), |
| 77 | Layer.provide(settings), | |
| 75 | 78 | ); |
| 76 | 79 | |
| 77 | 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 | 139 | it.effect("returns no-script when no setup script exists", () => { |
| 79 | 140 | const open = vi.fn(() => Effect.die("unexpected open")); |
| 80 | 141 | const write = vi.fn(() => Effect.die("unexpected write")); |
apps/server/src/project/ProjectSetupScriptRunner.ts+19-3| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 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 | 7 | import * as Context from "effect/Context"; |
| 4 | 8 | import * as Effect from "effect/Effect"; |
| 5 | 9 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -7,6 +11,7 @@ import * as Option from "effect/Option"; |
| 7 | 11 | import * as Schema from "effect/Schema"; |
| 8 | 12 | |
| 9 | 13 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 14 | import * as ServerSettings from "../serverSettings.ts"; | |
| 10 | 15 | import * as TerminalManager from "../terminal/Manager.ts"; |
| 11 | 16 | |
| 12 | 17 | export interface ProjectSetupScriptRunnerResultNoScript { |
| ... | ... | @@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass<Pr |
| 40 | 45 | projectId: Schema.optional(Schema.String), |
| 41 | 46 | projectCwd: Schema.optional(Schema.String), |
| 42 | 47 | worktreePath: Schema.String, |
| 43 | operation: Schema.Literals(["resolveProject", "openTerminal", "writeCommand"]), | |
| 48 | operation: Schema.Literals(["resolveProject", "readSettings", "openTerminal", "writeCommand"]), | |
| 44 | 49 | cause: Schema.Defect(), |
| 45 | 50 | }, |
| 46 | 51 | ) { |
| ... | ... | @@ -81,6 +86,7 @@ export class ProjectSetupScriptRunner extends Context.Service< |
| 81 | 86 | export const make = Effect.gen(function* () { |
| 82 | 87 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 83 | 88 | const terminalManager = yield* TerminalManager.TerminalManager; |
| 89 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 84 | 90 | |
| 85 | 91 | const runForThread: ProjectSetupScriptRunner["Service"]["runForThread"] = Effect.fn( |
| 86 | 92 | "ProjectSetupScriptRunner.runForThread", |
| ... | ... | @@ -124,7 +130,17 @@ export const make = Effect.gen(function* () { |
| 124 | 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 | 144 | if (!script) { |
| 129 | 145 | return { |
| 130 | 146 | status: "no-script", |
apps/server/src/provider/Layers/ProviderService.test.ts+80-2| ... | ... | @@ -19,6 +19,8 @@ import { |
| 19 | 19 | EnvironmentId, |
| 20 | 20 | EventId, |
| 21 | 21 | MessageId, |
| 22 | OrchestrationThreadShell, | |
| 23 | ProjectId, | |
| 22 | 24 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 23 | 25 | ProviderDriverKind, |
| 24 | 26 | ProviderInstanceId, |
| ... | ... | @@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts"; |
| 75 | 77 | import * as ServerSettings from "../../serverSettings.ts"; |
| 76 | 78 | import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; |
| 77 | 79 | import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; |
| 80 | import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 78 | 81 | |
| 79 | 82 | const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); |
| 80 | 83 | const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( |
| ... | ... | @@ -4302,10 +4305,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { |
| 4302 | 4305 | ); |
| 4303 | 4306 | }); |
| 4304 | 4307 | |
| 4308 | const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); | |
| 4309 | ||
| 4305 | 4310 | describe("agent browser access", () => { |
| 4306 | 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 | 4319 | Effect.gen(function* () { |
| 4310 | 4320 | const issued: Array<ThreadId> = []; |
| 4311 | 4321 | const codex = makeFakeCodexAdapter(); |
| ... | ... | @@ -4319,6 +4329,49 @@ describe("agent browser access", () => { |
| 4319 | 4329 | const directoryLayer = ProviderSessionDirectoryLive.pipe( |
| 4320 | 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 | 4375 | const providerLayer = makeProviderServiceLive({ |
| 4323 | 4376 | issueMcpCredential: (request) => |
| 4324 | 4377 | Effect.sync(() => { |
| ... | ... | @@ -4329,7 +4382,14 @@ describe("agent browser access", () => { |
| 4329 | 4382 | }).pipe( |
| 4330 | 4383 | Layer.provide(providerAdapterLayer), |
| 4331 | 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 | 4393 | Layer.provide(serverConfigTestLayer), |
| 4334 | 4394 | Layer.provide(AnalyticsService.layerTest), |
| 4335 | 4395 | Layer.provide( |
| ... | ... | @@ -4387,4 +4447,22 @@ describe("agent browser access", () => { |
| 4387 | 4447 | assert.deepEqual(issued, [threadId]); |
| 4388 | 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 | 32 | import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; |
| 33 | 33 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 34 | 34 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 35 | import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; | |
| 35 | 36 | import * as DateTime from "effect/DateTime"; |
| 36 | 37 | import * as Deferred from "effect/Deferred"; |
| 37 | 38 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -72,6 +73,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; |
| 72 | 73 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 73 | 74 | import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; |
| 74 | 75 | import * as ServerSettings from "../../serverSettings.ts"; |
| 76 | import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 75 | 77 | const isModelSelection = Schema.is(ModelSelection); |
| 76 | 78 | |
| 77 | 79 | interface PendingCompaction { |
| ... | ... | @@ -323,6 +325,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 323 | 325 | const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; |
| 324 | 326 | const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; |
| 325 | 327 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 328 | const projectionQuery = yield* Effect.serviceOption( | |
| 329 | ProjectionSnapshotQuery.ProjectionSnapshotQuery, | |
| 330 | ); | |
| 326 | 331 | const issueMcpCredential = |
| 327 | 332 | options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; |
| 328 | 333 | const revokeMcpCredential = |
| ... | ... | @@ -714,8 +719,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 714 | 719 | * "off" silently becoming "on" would violate the user's stated choice, |
| 715 | 720 | * whereas the reverse costs an agent one toolset and is visible immediately. |
| 716 | 721 | */ |
| 717 | const agentBrowserAccessEnabled = serverSettings.getSettings.pipe( | |
| 718 | Effect.map((settings) => settings.enableAgentBrowserAccess), | |
| 722 | const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( | |
| 723 | function* (threadId: ThreadId) { | |
| 724 | const settings = yield* serverSettings.getSettings; | |
| 725 | if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { | |
| 726 | return settings.enableAgentBrowserAccess; | |
| 727 | } | |
| 728 | // Provider-only runtimes may omit orchestration. An unresolved project | |
| 729 | // must not bypass an explicit browser override. | |
| 730 | if (Option.isNone(projectionQuery)) return false; | |
| 731 | const thread = yield* projectionQuery.value.getThreadShellById(threadId); | |
| 732 | if (Option.isNone(thread)) return false; | |
| 733 | return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); | |
| 734 | }, | |
| 719 | 735 | Effect.catch((cause) => |
| 720 | 736 | Effect.logWarning( |
| 721 | 737 | "Could not read server settings; withholding agent browser access for this session.", |
| ... | ... | @@ -726,7 +742,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 726 | 742 | |
| 727 | 743 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => |
| 728 | 744 | Effect.gen(function* () { |
| 729 | if (!(yield* agentBrowserAccessEnabled)) { | |
| 745 | if (!(yield* agentBrowserAccessEnabled(threadId))) { | |
| 730 | 746 | // Revoke as well as clear. Every other prepare path reaches |
| 731 | 747 | // `issueActiveMcpCredential`, which revokes the thread first, so |
| 732 | 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 | 317 | ); |
| 318 | 318 | |
| 319 | 319 | const GitManagerLayerLive = GitManager.layer.pipe( |
| 320 | Layer.provideMerge(ProjectSetupScriptRunner.layer), | |
| 320 | Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), | |
| 321 | 321 | Layer.provideMerge(GitVcsDriver.layer), |
| 322 | 322 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 323 | 323 | Layer.provideMerge(TextGeneration.layer), |
| ... | ... | @@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe( |
| 353 | 353 | Layer.provideMerge( |
| 354 | 354 | VcsStatusBroadcaster.layer.pipe( |
| 355 | 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 | 14 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 15 | 15 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 16 | 16 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 17 | import * as ServerSettings from "./serverSettings.ts"; | |
| 17 | 18 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 18 | 19 | |
| 19 | 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 | 41 | }), |
| 41 | 42 | } as unknown as GitVcsDriver.GitVcsDriver["Service"]; |
| 42 | 43 | const project = (workspaceRoot: string, autoPull = true) => |
| 43 | ({ workspaceRoot, autoPull }) as never; | |
| 44 | ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; | |
| 44 | 45 | |
| 45 | 46 | yield* ServerRuntimeStartup.autoPullProjects([ |
| 46 | 47 | project("/clean"), |
| ... | ... | @@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che |
| 52 | 53 | ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); |
| 53 | 54 | |
| 54 | 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 | 135 | return Effect.gen(function* () { |
| 125 | 136 | const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]); |
| 126 | 137 | const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( |
| 138 | Effect.provide(ServerSettings.layerTest()), | |
| 127 | 139 | Effect.provideService(ServerConfig.ServerConfig, { |
| 128 | 140 | cwd: "/tmp/startup-project", |
| 129 | 141 | autoBootstrapProjectFromCwd: true, |
| ... | ... | @@ -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 | 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 | 217 | const dispatchCalls = yield* Ref.make< |
| 195 | 218 | ReadonlyArray<{ |
| 196 | 219 | readonly type: string; |
| ... | ... | @@ -199,6 +222,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 199 | 222 | }> |
| 200 | 223 | >([]); |
| 201 | 224 | const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( |
| 225 | Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), | |
| 202 | 226 | Effect.provideService(ServerConfig.ServerConfig, { |
| 203 | 227 | cwd: "/tmp/startup-project", |
| 204 | 228 | autoBootstrapProjectFromCwd: true, |
| ... | ... | @@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 212 | 236 | getSnapshotSequence: () => Effect.die("unused"), |
| 213 | 237 | getCounts: () => Effect.die("unused"), |
| 214 | 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 | 254 | getProjectShellById: () => Effect.die("unused"), |
| 217 | 255 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 218 | 256 | getImportedAgentSessionSources: () => Effect.die("unused"), |
| ... | ... | @@ -241,18 +279,22 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when |
| 241 | 279 | |
| 242 | 280 | assert.equal(typeof targets.bootstrapProjectId, "string"); |
| 243 | 281 | assert.equal(typeof targets.bootstrapThreadId, "string"); |
| 244 | assert.equal(targets.bootstrapProjectCreated, true); | |
| 282 | assert.equal(targets.bootstrapProjectCreated, !existing); | |
| 245 | 283 | assert.equal(targets.bootstrapThreadCreated, true); |
| 246 | 284 | const commands = yield* Ref.get(dispatchCalls); |
| 247 | 285 | assert.deepStrictEqual( |
| 248 | 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 | 304 | Effect.gen(function* () { |
| 263 | 305 | const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]); |
| 264 | 306 | const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( |
| 307 | Effect.provide(ServerSettings.layerTest()), | |
| 265 | 308 | Effect.provideService(ServerConfig.ServerConfig, { |
| 266 | 309 | cwd: "/tmp/startup-project", |
| 267 | 310 | autoBootstrapProjectFromCwd: true, |
| ... | ... | @@ -322,6 +365,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa |
| 322 | 365 | const dispatchCalls = yield* Ref.make<ReadonlyArray<string>>([]); |
| 323 | 366 | |
| 324 | 367 | const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( |
| 368 | Effect.provide(ServerSettings.layerTest()), | |
| 325 | 369 | Effect.provideService(ServerConfig.ServerConfig, { |
| 326 | 370 | cwd: "/tmp/startup-project", |
| 327 | 371 | autoBootstrapProjectFromCwd: true, |
apps/server/src/serverRuntimeStartup.ts+17-4| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | CommandId, |
| 3 | 3 | DEFAULT_MODEL, |
| 4 | 4 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 5 | DEFAULT_SERVER_SETTINGS, | |
| 5 | 6 | type ModelSelection, |
| 6 | 7 | type OrchestrationProjectShell, |
| 7 | 8 | ProjectId, |
| ... | ... | @@ -9,6 +10,7 @@ import { |
| 9 | 10 | ThreadId, |
| 10 | 11 | TurnId, |
| 11 | 12 | } from "@t3tools/contracts"; |
| 13 | import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; | |
| 12 | 14 | import * as Cause from "effect/Cause"; |
| 13 | 15 | import * as Console from "effect/Console"; |
| 14 | 16 | import * as Context from "effect/Context"; |
| ... | ... | @@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 198 | 200 | let bootstrapThreadCreated = false; |
| 199 | 201 | |
| 200 | 202 | if (serverConfig.autoBootstrapProjectFromCwd) { |
| 203 | const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; | |
| 204 | const defaultModelSelection = | |
| 205 | settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); | |
| 201 | 206 | yield* Effect.gen(function* () { |
| 202 | 207 | const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( |
| 203 | 208 | serverConfig.cwd, |
| ... | ... | @@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 209 | 214 | const createdAt = DateTime.formatIso(yield* DateTime.now); |
| 210 | 215 | nextProjectId = ProjectId.make(yield* randomUUID); |
| 211 | 216 | const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; |
| 212 | nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); | |
| 217 | nextThreadModelSelection = defaultModelSelection; | |
| 213 | 218 | yield* orchestrationEngine.dispatch({ |
| 214 | 219 | type: "project.create", |
| 215 | 220 | commandId: CommandId.make(yield* randomUUID), |
| ... | ... | @@ -224,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { |
| 224 | 229 | nextProjectId = existingProject.value.id; |
| 225 | 230 | bootstrapProjectId = nextProjectId; |
| 226 | 231 | nextThreadModelSelection = |
| 227 | existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); | |
| 232 | existingProject.value.defaultModelSelection ?? defaultModelSelection; | |
| 228 | 233 | } |
| 229 | 234 | |
| 230 | 235 | yield* Effect.gen(function* () { |
| ... | ... | @@ -737,12 +742,16 @@ interface StartupOptions { |
| 737 | 742 | |
| 738 | 743 | export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( |
| 739 | 744 | projects: ReadonlyArray<OrchestrationProjectShell>, |
| 745 | settings: Pick< | |
| 746 | typeof DEFAULT_SERVER_SETTINGS, | |
| 747 | "defaultAutoPull" | "projectAutoPullOverrides" | |
| 748 | > = DEFAULT_SERVER_SETTINGS, | |
| 740 | 749 | ) { |
| 741 | 750 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 742 | 751 | const workspaceRoots = [ |
| 743 | 752 | ...new Set( |
| 744 | 753 | projects |
| 745 | .filter((project) => project.autoPull === true) | |
| 754 | .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) | |
| 746 | 755 | .map((project) => project.workspaceRoot), |
| 747 | 756 | ), |
| 748 | 757 | ]; |
| ... | ... | @@ -813,7 +822,11 @@ export const make = (options?: StartupOptions) => |
| 813 | 822 | const reactorScope = yield* Scope.make("sequential"); |
| 814 | 823 | |
| 815 | 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 | 830 | Effect.catch((cause) => |
| 818 | 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 | 22 | VcsStatusStreamEvent, |
| 23 | 23 | } from "@t3tools/contracts"; |
| 24 | 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; |
| 25 | import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; | |
| 25 | 26 | |
| 26 | 27 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 27 | 28 | import * as GitWorkflowService from "../git/GitWorkflowService.ts"; |
| 28 | 29 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 30 | import * as ServerSettings from "../serverSettings.ts"; | |
| 29 | 31 | |
| 30 | 32 | const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); |
| 31 | 33 | const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); |
| ... | ... | @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( |
| 151 | 153 | VcsAutoPullPolicy, |
| 152 | 154 | Effect.gen(function* () { |
| 153 | 155 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 156 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 154 | 157 | return { |
| 155 | isEnabled: (cwd: string) => | |
| 156 | snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( | |
| 157 | Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), | |
| 158 | Effect.orElseSucceed(() => false), | |
| 159 | ), | |
| 158 | isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( | |
| 159 | function* (cwd: string) { | |
| 160 | const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); | |
| 161 | if (project._tag === "None") return false; | |
| 162 | const settings = yield* serverSettings.getSettings; | |
| 163 | return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); | |
| 164 | }, | |
| 165 | Effect.orElseSucceed(() => false), | |
| 166 | ), | |
| 160 | 167 | }; |
| 161 | 168 | }), |
| 162 | 169 | ); |
apps/web/src/components/ChatView.tsx+53-36| ... | ... | @@ -53,7 +53,11 @@ import { |
| 53 | 53 | createModelSelection, |
| 54 | 54 | resolvePromptInjectedEffort, |
| 55 | 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 | 61 | import { truncate } from "@t3tools/shared/String"; |
| 58 | 62 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; |
| 59 | 63 | import { |
| ... | ... | @@ -276,7 +280,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi |
| 276 | 280 | import { environmentCatalog } from "../connection/catalog"; |
| 277 | 281 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 278 | 282 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 279 | import { projectEnvironment } from "../state/projects"; | |
| 280 | 283 | import { useEnvironmentQuery } from "../state/query"; |
| 281 | 284 | import { |
| 282 | 285 | environmentServerConfigsAtom, |
| ... | ... | @@ -1386,7 +1389,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1386 | 1389 | [environmentId, threadId], |
| 1387 | 1390 | ); |
| 1388 | 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 | 1395 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { |
| 1391 | 1396 | reportFailure: false, |
| 1392 | 1397 | }); |
| ... | ... | @@ -1474,9 +1479,6 @@ export default function ChatView(props: ChatViewProps) { |
| 1474 | 1479 | }, [routeKind, routeThreadRef, routeThreadState]); |
| 1475 | 1480 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1476 | 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 | 1482 | const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); |
| 1481 | 1483 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 1482 | 1484 | (store) => store.setStickyModelSelection, |
| ... | ... | @@ -1758,10 +1760,17 @@ export default function ChatView(props: ChatViewProps) { |
| 1758 | 1760 | ? buildLocalDraftThread( |
| 1759 | 1761 | threadId, |
| 1760 | 1762 | draftThread, |
| 1761 | fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, | |
| 1763 | fallbackDraftProject?.defaultModelSelection ?? | |
| 1764 | settings.defaultModelSelection ?? | |
| 1765 | NO_PROVIDER_MODEL_SELECTION, | |
| 1762 | 1766 | ) |
| 1763 | 1767 | : undefined, |
| 1764 | [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], | |
| 1768 | [ | |
| 1769 | draftThread, | |
| 1770 | fallbackDraftProject?.defaultModelSelection, | |
| 1771 | settings.defaultModelSelection, | |
| 1772 | threadId, | |
| 1773 | ], | |
| 1765 | 1774 | ); |
| 1766 | 1775 | // Promotion is data-driven: the draft route keeps rendering while the |
| 1767 | 1776 | // server thread (same pre-allocated ref) starts, so live state must not |
| ... | ... | @@ -1987,6 +1996,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1987 | 1996 | [activeThread?.environmentId, activeThread?.projectId], |
| 1988 | 1997 | ); |
| 1989 | 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 | 2005 | const handleNewThreadInActiveProject = useCallback(() => { |
| 1991 | 2006 | startNewThreadForProject(activeProjectRef, handleNewThread); |
| 1992 | 2007 | }, [activeProjectRef, handleNewThread]); |
| ... | ... | @@ -2035,8 +2050,8 @@ export default function ChatView(props: ChatViewProps) { |
| 2035 | 2050 | [activeProjectKey], |
| 2036 | 2051 | ); |
| 2037 | 2052 | const configuredPreviewUrls = useMemo( |
| 2038 | () => getConfiguredPreviewUrls(activeProject?.scripts), | |
| 2039 | [activeProject?.scripts], | |
| 2053 | () => getConfiguredPreviewUrls(activeProjectScripts), | |
| 2054 | [activeProjectScripts], | |
| 2040 | 2055 | ); |
| 2041 | 2056 | |
| 2042 | 2057 | useEffect(() => { |
| ... | ... | @@ -2280,7 +2295,7 @@ export default function ChatView(props: ChatViewProps) { |
| 2280 | 2295 | const selectedProviderByThreadId = composerActiveProvider ?? null; |
| 2281 | 2296 | const threadProvider = |
| 2282 | 2297 | activeThread?.modelSelection.instanceId ?? |
| 2283 | activeProject?.defaultModelSelection?.instanceId ?? | |
| 2298 | activeProjectDefaultModelSelection?.instanceId ?? | |
| 2284 | 2299 | null; |
| 2285 | 2300 | const lockedProvider = deriveLockedProvider({ |
| 2286 | 2301 | thread: activeThread, |
| ... | ... | @@ -2514,14 +2529,14 @@ export default function ChatView(props: ChatViewProps) { |
| 2514 | 2529 | selectedProviderByThreadId, |
| 2515 | 2530 | activeThread?.session?.providerInstanceId, |
| 2516 | 2531 | activeThread?.modelSelection.instanceId, |
| 2517 | activeProject?.defaultModelSelection?.instanceId, | |
| 2532 | activeProjectDefaultModelSelection?.instanceId, | |
| 2518 | 2533 | ], |
| 2519 | 2534 | lockedProvider, |
| 2520 | 2535 | lockedInstanceId: |
| 2521 | 2536 | activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId, |
| 2522 | 2537 | }), |
| 2523 | 2538 | [ |
| 2524 | activeProject?.defaultModelSelection?.instanceId, | |
| 2539 | activeProjectDefaultModelSelection?.instanceId, | |
| 2525 | 2540 | activeThread?.modelSelection.instanceId, |
| 2526 | 2541 | activeThread?.session?.providerInstanceId, |
| 2527 | 2542 | lockedProvider, |
| ... | ... | @@ -3652,11 +3667,14 @@ export default function ChatView(props: ChatViewProps) { |
| 3652 | 3667 | keybindingCommand: KeybindingCommand; |
| 3653 | 3668 | }): Promise<AtomCommandResult<void, unknown>> => { |
| 3654 | 3669 | const updateResult = mapAtomCommandResult( |
| 3655 | await updateProject({ | |
| 3670 | await updateProjectScriptSettings({ | |
| 3656 | 3671 | environmentId, |
| 3657 | 3672 | input: { |
| 3658 | projectId: input.projectId, | |
| 3659 | scripts: input.nextScripts, | |
| 3673 | patch: { | |
| 3674 | projectScriptOverrides: { | |
| 3675 | [input.projectId]: input.nextScripts, | |
| 3676 | }, | |
| 3677 | }, | |
| 3660 | 3678 | }, |
| 3661 | 3679 | }), |
| 3662 | 3680 | () => undefined, |
| ... | ... | @@ -3681,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3681 | 3699 | } |
| 3682 | 3700 | return updateResult; |
| 3683 | 3701 | }, |
| 3684 | [environmentId, updateProject, upsertKeybinding], | |
| 3702 | [environmentId, updateProjectScriptSettings, upsertKeybinding], | |
| 3685 | 3703 | ); |
| 3686 | 3704 | const saveProjectScript = useCallback( |
| 3687 | 3705 | async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => { |
| ... | ... | @@ -3690,28 +3708,28 @@ export default function ChatView(props: ChatViewProps) { |
| 3690 | 3708 | } |
| 3691 | 3709 | const nextId = nextProjectScriptId( |
| 3692 | 3710 | input.name, |
| 3693 | activeProject.scripts.map((script) => script.id), | |
| 3711 | activeProjectScripts.map((script) => script.id), | |
| 3694 | 3712 | ); |
| 3695 | 3713 | const nextScript = buildProjectScript(nextId, input); |
| 3696 | 3714 | const nextScripts = input.runOnWorktreeCreate |
| 3697 | 3715 | ? [ |
| 3698 | ...activeProject.scripts.map((script) => | |
| 3716 | ...activeProjectScripts.map((script) => | |
| 3699 | 3717 | script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, |
| 3700 | 3718 | ), |
| 3701 | 3719 | nextScript, |
| 3702 | 3720 | ] |
| 3703 | : [...activeProject.scripts, nextScript]; | |
| 3721 | : [...activeProjectScripts, nextScript]; | |
| 3704 | 3722 | |
| 3705 | 3723 | return persistProjectScripts({ |
| 3706 | 3724 | projectId: activeProject.id, |
| 3707 | 3725 | projectCwd: activeProject.workspaceRoot, |
| 3708 | previousScripts: activeProject.scripts, | |
| 3726 | previousScripts: activeProjectScripts, | |
| 3709 | 3727 | nextScripts, |
| 3710 | 3728 | keybinding: input.keybinding, |
| 3711 | 3729 | keybindingCommand: commandForProjectScript(nextId), |
| 3712 | 3730 | }); |
| 3713 | 3731 | }, |
| 3714 | [activeProject, persistProjectScripts], | |
| 3732 | [activeProject, activeProjectScripts, persistProjectScripts], | |
| 3715 | 3733 | ); |
| 3716 | 3734 | const updateProjectScript = useCallback( |
| 3717 | 3735 | async ( |
| ... | ... | @@ -3721,13 +3739,13 @@ export default function ChatView(props: ChatViewProps) { |
| 3721 | 3739 | if (!activeProject) { |
| 3722 | 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 | 3743 | if (!existingScript) { |
| 3726 | 3744 | return AsyncResult.failure(Cause.fail(new Error("Script not found."))); |
| 3727 | 3745 | } |
| 3728 | 3746 | |
| 3729 | 3747 | const updatedScript = buildProjectScript(existingScript.id, input); |
| 3730 | const nextScripts = activeProject.scripts.map((script) => | |
| 3748 | const nextScripts = activeProjectScripts.map((script) => | |
| 3731 | 3749 | script.id === scriptId |
| 3732 | 3750 | ? updatedScript |
| 3733 | 3751 | : input.runOnWorktreeCreate |
| ... | ... | @@ -3738,27 +3756,27 @@ export default function ChatView(props: ChatViewProps) { |
| 3738 | 3756 | return persistProjectScripts({ |
| 3739 | 3757 | projectId: activeProject.id, |
| 3740 | 3758 | projectCwd: activeProject.workspaceRoot, |
| 3741 | previousScripts: activeProject.scripts, | |
| 3759 | previousScripts: activeProjectScripts, | |
| 3742 | 3760 | nextScripts, |
| 3743 | 3761 | keybinding: input.keybinding, |
| 3744 | 3762 | keybindingCommand: commandForProjectScript(scriptId), |
| 3745 | 3763 | }); |
| 3746 | 3764 | }, |
| 3747 | [activeProject, persistProjectScripts], | |
| 3765 | [activeProject, activeProjectScripts, persistProjectScripts], | |
| 3748 | 3766 | ); |
| 3749 | 3767 | const deleteProjectScript = useCallback( |
| 3750 | 3768 | async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => { |
| 3751 | 3769 | if (!activeProject) { |
| 3752 | 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 | 3776 | const result = await persistProjectScripts({ |
| 3759 | 3777 | projectId: activeProject.id, |
| 3760 | 3778 | projectCwd: activeProject.workspaceRoot, |
| 3761 | previousScripts: activeProject.scripts, | |
| 3779 | previousScripts: activeProjectScripts, | |
| 3762 | 3780 | nextScripts, |
| 3763 | 3781 | keybinding: null, |
| 3764 | 3782 | keybindingCommand: commandForProjectScript(scriptId), |
| ... | ... | @@ -3780,7 +3798,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3780 | 3798 | } |
| 3781 | 3799 | return result; |
| 3782 | 3800 | }, |
| 3783 | [activeProject, persistProjectScripts], | |
| 3801 | [activeProject, activeProjectScripts, persistProjectScripts], | |
| 3784 | 3802 | ); |
| 3785 | 3803 | |
| 3786 | 3804 | const handleRuntimeModeChange = useCallback( |
| ... | ... | @@ -6061,7 +6079,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6061 | 6079 | |
| 6062 | 6080 | const scriptId = projectScriptIdFromCommand(command); |
| 6063 | 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 | 6083 | if (!script) return; |
| 6066 | 6084 | event.preventDefault(); |
| 6067 | 6085 | event.stopPropagation(); |
| ... | ... | @@ -6072,6 +6090,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6072 | 6090 | }, [ |
| 6073 | 6091 | activeProject, |
| 6074 | 6092 | activeRightPanelSurface, |
| 6093 | activeProjectScripts, | |
| 6075 | 6094 | addTerminalSurface, |
| 6076 | 6095 | activeThreadRef, |
| 6077 | 6096 | activeThreadPinned, |
| ... | ... | @@ -6747,7 +6766,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6747 | 6766 | const title = truncate(titleSeed); |
| 6748 | 6767 | const threadCreateModelSelection = createModelSelection( |
| 6749 | 6768 | ctxSelectedModelSelection.instanceId, |
| 6750 | ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, | |
| 6769 | ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, | |
| 6751 | 6770 | ctxSelectedModelSelection.options, |
| 6752 | 6771 | ); |
| 6753 | 6772 | |
| ... | ... | @@ -7885,7 +7904,7 @@ export default function ChatView(props: ChatViewProps) { |
| 7885 | 7904 | activeProjectFaviconPath={activeProject?.faviconPath ?? null} |
| 7886 | 7905 | activeProjectIcon={activeProject?.projectIcon ?? null} |
| 7887 | 7906 | openInCwd={gitCwd} |
| 7888 | activeProjectScripts={activeProject?.scripts} | |
| 7907 | activeProjectScripts={activeProjectScripts} | |
| 7889 | 7908 | preferredScriptId={ |
| 7890 | 7909 | activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null |
| 7891 | 7910 | } |
| ... | ... | @@ -8124,9 +8143,7 @@ export default function ChatView(props: ChatViewProps) { |
| 8124 | 8143 | interactionMode={interactionMode} |
| 8125 | 8144 | lockedProvider={lockedProvider} |
| 8126 | 8145 | providerStatuses={providerStatuses as ServerProvider[]} |
| 8127 | activeProjectDefaultModelSelection={ | |
| 8128 | activeProject?.defaultModelSelection | |
| 8129 | } | |
| 8146 | activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} | |
| 8130 | 8147 | activeThreadModelSelection={activeThread?.modelSelection} |
| 8131 | 8148 | activeContextWindow={activeContextWindow} |
| 8132 | 8149 | compactThreadUnavailable={compactThreadUnavailable} |
apps/web/src/components/CommandPalette.tsx+2| ... | ... | @@ -1791,6 +1791,8 @@ function OpenCommandPaletteDialog(props: { |
| 1791 | 1791 | run: async () => { |
| 1792 | 1792 | await navigate({ |
| 1793 | 1793 | to: item.to, |
| 1794 | search: (previous) => | |
| 1795 | item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, | |
| 1794 | 1796 | hash: item.targetId ?? item.id, |
| 1795 | 1797 | replace: pathname === item.to, |
| 1796 | 1798 | hashScrollIntoView: false, |
apps/web/src/components/chat/DraftHeroHeadline.tsx+7-2| ... | ... | @@ -149,8 +149,13 @@ export function DraftHeroHeadline({ |
| 149 | 149 | ); |
| 150 | 150 | if (!hasExplicitComposerModelSelection(currentDraft)) { |
| 151 | 151 | applyStickyState(draftId); |
| 152 | if (project.defaultModelSelection) { | |
| 153 | setModelSelection(draftId, project.defaultModelSelection, { | |
| 152 | const defaultModelSelection = | |
| 153 | project.defaultModelSelection ?? | |
| 154 | environments.find( | |
| 155 | (environment) => environment.environmentId === project.environmentId, | |
| 156 | )?.serverConfig?.settings.defaultModelSelection; | |
| 157 | if (defaultModelSelection) { | |
| 158 | setModelSelection(draftId, defaultModelSelection, { | |
| 154 | 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 | 6 | import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; |
| 7 | 7 | import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; |
| 8 | 8 | import { newProjectId } from "../../lib/utils"; |
| 9 | import { resolveDefaultProviderModelSelection } from "../../providerInstances"; | |
| 10 | 9 | import { readProjects, waitForProject } from "../../state/entities"; |
| 11 | 10 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 12 | 11 | import { projectEnvironment } from "../../state/projects"; |
| ... | ... | @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { |
| 52 | 51 | ) ?? null, |
| 53 | 52 | createProject: async (environmentId, workspaceRoot) => { |
| 54 | 53 | const projectId = newProjectId(); |
| 55 | const providers = | |
| 56 | primaryEnvironment?.environmentId === environmentId | |
| 57 | ? (primaryEnvironment.serverConfig?.providers ?? []) | |
| 58 | : []; | |
| 59 | 54 | const result = await createProject({ |
| 60 | 55 | environmentId, |
| 61 | 56 | input: { |
| ... | ... | @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { |
| 63 | 58 | title: inferProjectTitleFromPath(workspaceRoot), |
| 64 | 59 | workspaceRoot, |
| 65 | 60 | createWorkspaceRootIfMissing: false, |
| 66 | defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), | |
| 61 | defaultModelSelection: null, | |
| 67 | 62 | }, |
| 68 | 63 | }); |
| 69 | 64 | if (result._tag === "Failure") { |
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-6| ... | ... | @@ -51,7 +51,6 @@ import { |
| 51 | 51 | } from "../../onboarding/targetEnvironment.logic"; |
| 52 | 52 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 53 | 53 | import { newProjectId, randomUUID } from "../../lib/utils"; |
| 54 | import { resolveDefaultProviderModelSelection } from "../../providerInstances"; | |
| 55 | 54 | import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; |
| 56 | 55 | import { readProjects, useProjects } from "../../state/entities"; |
| 57 | 56 | import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; |
| ... | ... | @@ -1029,9 +1028,6 @@ function ImportStep({ |
| 1029 | 1028 | const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); |
| 1030 | 1029 | const environmentId = targetEnvironment?.environmentId ?? null; |
| 1031 | 1030 | const machineLabel = targetEnvironment?.label ?? "this machine"; |
| 1032 | const providers = useAtomValue( | |
| 1033 | serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), | |
| 1034 | ); | |
| 1035 | 1031 | const scan = useEnvironmentQuery( |
| 1036 | 1032 | environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), |
| 1037 | 1033 | ); |
| ... | ... | @@ -1123,7 +1119,6 @@ function ImportStep({ |
| 1123 | 1119 | const importGeneration = importGenerationRef.current; |
| 1124 | 1120 | const importedProjects = importedProjectsRef.current; |
| 1125 | 1121 | const projectAttempts = projectAttemptsRef.current; |
| 1126 | const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); | |
| 1127 | 1122 | // Interrupted imports are neither failures nor successes — the command was |
| 1128 | 1123 | // superseded or the environment dropped — but they still didn't land, so |
| 1129 | 1124 | // they must not read as "imported everything". Retries skip paths that |
| ... | ... | @@ -1164,7 +1159,7 @@ function ImportStep({ |
| 1164 | 1159 | title: candidate.title, |
| 1165 | 1160 | workspaceRoot: candidate.path, |
| 1166 | 1161 | createWorkspaceRootIfMissing: false, |
| 1167 | defaultModelSelection, | |
| 1162 | defaultModelSelection: null, | |
| 1168 | 1163 | }, |
| 1169 | 1164 | }); |
| 1170 | 1165 | if ( |
apps/web/src/components/settings/IntegrationsSettings.test.tsx+13-1| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | 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 | 8 | import { act, StrictMode, type ReactNode } from "react"; |
| 3 | 9 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 4 | 10 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -44,13 +50,19 @@ afterEach(async () => { |
| 44 | 50 | }); |
| 45 | 51 | |
| 46 | 52 | async function openSettings() { |
| 53 | const router = createRouter({ | |
| 54 | routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), | |
| 55 | history: createMemoryHistory(), | |
| 56 | }); | |
| 57 | await router.load(); | |
| 47 | 58 | await act(() => { |
| 48 | 59 | renderer = create( |
| 49 | 60 | <StrictMode> |
| 50 | <IntegrationsSettingsPanel /> | |
| 61 | <RouterProvider router={router} /> | |
| 51 | 62 | </StrictMode>, |
| 52 | 63 | ); |
| 53 | 64 | }); |
| 65 | expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); | |
| 54 | 66 | } |
| 55 | 67 | |
| 56 | 68 | describe("Integrations browser discovery", () => { |
apps/web/src/components/settings/IntegrationsSettings.tsx+10-30| ... | ... | @@ -20,7 +20,6 @@ import { |
| 20 | 20 | DEFAULT_BROWSER_RECORDING_FRAME_RATE, |
| 21 | 21 | DEFAULT_BROWSER_VIEWPORT, |
| 22 | 22 | DEFAULT_PREVIEW_APPEARANCE, |
| 23 | DEFAULT_UNIFIED_SETTINGS, | |
| 24 | 23 | DEFAULT_PREVIEW_ZOOM_FACTOR, |
| 25 | 24 | FILL_PREVIEW_VIEWPORT, |
| 26 | 25 | PREVIEW_VIEWPORT_MAX_AREA, |
| ... | ... | @@ -35,6 +34,7 @@ import { |
| 35 | 34 | type PreviewViewportSetting, |
| 36 | 35 | } from "@t3tools/contracts"; |
| 37 | 36 | import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; |
| 37 | import { Link } from "@tanstack/react-router"; | |
| 38 | 38 | import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; |
| 39 | 39 | import { useCallback, useRef, useState, type ReactNode } from "react"; |
| 40 | 40 | |
| ... | ... | @@ -86,7 +86,6 @@ import { |
| 86 | 86 | persistClientSettingsUpdate, |
| 87 | 87 | useClientSettings, |
| 88 | 88 | useClientSettingsHydrated, |
| 89 | usePrimarySettings, | |
| 90 | 89 | useUpdatePrimarySettings, |
| 91 | 90 | } from "~/hooks/useSettings"; |
| 92 | 91 | |
| ... | ... | @@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) |
| 552 | 551 | } |
| 553 | 552 | |
| 554 | 553 | function AgentBrowserAccessSetting() { |
| 555 | const settings = usePrimarySettings(); | |
| 556 | const updateSettings = useUpdatePrimarySettings(); | |
| 557 | ||
| 558 | 554 | return ( |
| 559 | 555 | <SettingsRow |
| 560 | serverScoped | |
| 561 | 556 | {...searchableSetting("agent-browser-access")} |
| 562 | description="Allow agents to use the preview browser. Off hides browser tools from agents, not you." | |
| 563 | status={ | |
| 564 | settings.enableAgentBrowserAccess | |
| 565 | ? undefined | |
| 566 | : "Applies to sessions started from now on; a running agent keeps the tools it was given." | |
| 567 | } | |
| 568 | resetAction={ | |
| 569 | settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? ( | |
| 570 | <SettingResetButton | |
| 571 | label="agent browser access" | |
| 572 | onClick={() => | |
| 573 | updateSettings({ | |
| 574 | enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, | |
| 575 | }) | |
| 576 | } | |
| 577 | /> | |
| 578 | ) : null | |
| 579 | } | |
| 557 | description="Choose whether agents can use the preview browser for all projects or a specific project." | |
| 580 | 558 | control={ |
| 581 | <Switch | |
| 582 | checked={settings.enableAgentBrowserAccess} | |
| 583 | onCheckedChange={(checked) => | |
| 584 | updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) | |
| 559 | <Button | |
| 560 | render={ | |
| 561 | <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} /> | |
| 585 | 562 | } |
| 586 | aria-label="Allow agent browser access" | |
| 587 | /> | |
| 563 | size="sm" | |
| 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 | 12 | deriveProjectGroupingOverrideKey, |
| 13 | 13 | selectProjectGroupingSettings, |
| 14 | 14 | } from "../../logicalProject"; |
| 15 | import type { | |
| 16 | ContextMenuItem, | |
| 17 | ModelSelection, | |
| 18 | ProjectIconOverride, | |
| 19 | ProviderDriverKind, | |
| 20 | SidebarProjectGroupingMode, | |
| 21 | T3ProjectFileScript, | |
| 22 | ThreadEnvMode, | |
| 15 | import { | |
| 16 | type EnvironmentId, | |
| 17 | type ModelSelection, | |
| 18 | type ProjectIconOverride, | |
| 19 | type ProjectId, | |
| 20 | type ProjectScript, | |
| 21 | type ResolvedKeybindingsConfig, | |
| 22 | type ServerSettings, | |
| 23 | type ProviderDriverKind, | |
| 24 | type SidebarProjectGroupingMode, | |
| 25 | type T3ProjectFileScript, | |
| 26 | type ThreadEnvMode, | |
| 23 | 27 | } from "@t3tools/contracts"; |
| 24 | 28 | import { resolveEnvModeLabel } from "../BranchToolbar.logic"; |
| 25 | 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 | 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 | 38 | import * as Cause from "effect/Cause"; |
| 29 | import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; | |
| 30 | import { | |
| 31 | lazy, | |
| 32 | Suspense, | |
| 33 | useCallback, | |
| 34 | useEffect, | |
| 35 | useMemo, | |
| 36 | useRef, | |
| 37 | useState, | |
| 38 | type MouseEvent as ReactMouseEvent, | |
| 39 | } from "react"; | |
| 39 | import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; | |
| 40 | import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 40 | 41 | |
| 41 | 42 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 42 | import { isElectron } from "../../env"; | |
| 43 | 43 | import { |
| 44 | 44 | useClientSettings, |
| 45 | 45 | useEnvironmentSettings, |
| 46 | 46 | useUpdateClientSettings, |
| 47 | usePrimarySettings, | |
| 48 | 47 | } from "../../hooks/useSettings"; |
| 49 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | |
| 50 | 48 | import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; |
| 51 | import { shortcutLabelForCommand } from "../../keybindings"; | |
| 52 | import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; | |
| 53 | import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; | |
| 54 | import { readLocalApi } from "../../localApi"; | |
| 49 | import { ProjectActionsList } from "./ProjectActionsList"; | |
| 50 | import { isElectron } from "../../env"; | |
| 51 | import { | |
| 52 | decodeProjectScriptKeybindingRule, | |
| 53 | keybindingValueForCommand, | |
| 54 | } from "../../lib/projectScriptKeybindings"; | |
| 55 | 55 | import { |
| 56 | 56 | buildProjectScript, |
| 57 | 57 | commandForProjectScript, |
| 58 | 58 | nextProjectScriptId, |
| 59 | 59 | } from "../../projectScripts"; |
| 60 | import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; | |
| 60 | import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; | |
| 61 | import { readLocalApi } from "../../localApi"; | |
| 61 | 62 | import { |
| 62 | 63 | applyProviderInstanceSettings, |
| 63 | 64 | deriveProviderInstanceEntries, |
| ... | ... | @@ -98,16 +99,8 @@ import { |
| 98 | 99 | MenuTrigger, |
| 99 | 100 | } from "../ui/menu"; |
| 100 | 101 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 101 | import { SidebarInset } from "../ui/sidebar"; | |
| 102 | 102 | import { Switch } from "../ui/switch"; |
| 103 | 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 | 104 | import { |
| 112 | 105 | SETTINGS_PICKER_TRIGGER_CLASSNAME, |
| 113 | 106 | SettingResetButton, |
| ... | ... | @@ -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 | 124 | repository: "Group by repository", |
| 132 | 125 | repository_path: "Group by repository path", |
| 133 | 126 | separate: "Keep separate", |
| 134 | 127 | }; |
| 135 | 128 | |
| 136 | 129 | /** Logical project groups for the settings page, sorted by display name. */ |
| 137 | function useSettingsProjectGroups(): SidebarProjectSnapshot[] { | |
| 130 | export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { | |
| 138 | 131 | const projects = useProjects(); |
| 139 | 132 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 140 | 133 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| ... | ... | @@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string { |
| 162 | 155 | return `${member.environmentId}:${member.id}`; |
| 163 | 156 | } |
| 164 | 157 | |
| 165 | export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { | |
| 166 | const navigate = useNavigate(); | |
| 167 | const canGoBack = useCanGoBack(); | |
| 168 | const navigateBackWithinApp = useCallback(() => { | |
| 169 | if (canGoBack) { | |
| 170 | window.history.back(); | |
| 171 | return; | |
| 172 | } | |
| 173 | void navigate({ to: "/" }); | |
| 174 | }, [canGoBack, navigate]); | |
| 175 | ||
| 176 | useEffect(() => { | |
| 177 | const onKeyDown = (event: KeyboardEvent) => { | |
| 178 | if (event.defaultPrevented) return; | |
| 179 | if (event.key !== "Escape") return; | |
| 180 | event.preventDefault(); | |
| 181 | const activeElement = document.activeElement; | |
| 182 | if (activeElement instanceof HTMLElement) { | |
| 183 | activeElement.blur(); | |
| 184 | } | |
| 185 | navigateBackWithinApp(); | |
| 186 | }; | |
| 187 | window.addEventListener("keydown", onKeyDown); | |
| 188 | return () => window.removeEventListener("keydown", onKeyDown); | |
| 189 | }, [navigateBackWithinApp]); | |
| 190 | ||
| 191 | return ( | |
| 192 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> | |
| 193 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | |
| 194 | <WorkspacePageHeader electron={isElectron}> | |
| 195 | <ProjectSettingsBreadcrumb projectKey={projectKey} /> | |
| 196 | </WorkspacePageHeader> | |
| 197 | <ProjectSettingsPanel projectKey={projectKey} /> | |
| 198 | </div> | |
| 199 | </SidebarInset> | |
| 200 | ); | |
| 201 | } | |
| 202 | ||
| 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 }) { | |
| 158 | export function ProjectSettingsPanel({ | |
| 159 | projectKey, | |
| 160 | environmentId = null, | |
| 161 | }: { | |
| 162 | projectKey: string; | |
| 163 | environmentId?: EnvironmentId | null; | |
| 164 | }) { | |
| 257 | 165 | const groups = useSettingsProjectGroups(); |
| 258 | 166 | const navigate = useNavigate(); |
| 259 | 167 | |
| 260 | 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 | 177 | // Remember the members of the last rendered group so a grouping-rule change |
| 263 | 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 | 184 | useEffect(() => { |
| 266 | if (!selected) return; | |
| 185 | if (!selected || members.length === 0) return; | |
| 267 | 186 | lastSelectionRef.current = { |
| 268 | 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 | 193 | // A grouping-rule change replaces the group key mid-visit; follow the |
| 274 | 194 | // project to its new key instead of parking on the not-found state. |
| 275 | 195 | useEffect(() => { |
| 276 | if (selected !== null) return; | |
| 196 | if (members.length > 0) return; | |
| 277 | 197 | const last = lastSelectionRef.current; |
| 278 | if (last?.key !== projectKey) return; | |
| 198 | if (last?.key !== projectKey || last.environmentId !== environmentId) return; | |
| 279 | 199 | const successor = groups.find((group) => |
| 280 | 200 | group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), |
| 281 | 201 | ); |
| 282 | 202 | if (successor) { |
| 283 | 203 | void navigate({ |
| 284 | to: "/projects/$projectKey", | |
| 285 | params: { projectKey: successor.projectKey }, | |
| 204 | to: "/settings/projects", | |
| 205 | search: { project: successor.projectKey, machine: environmentId ?? undefined }, | |
| 286 | 206 | replace: true, |
| 287 | 207 | hashScrollIntoView: false, |
| 288 | 208 | }); |
| 289 | 209 | } |
| 290 | }, [groups, navigate, projectKey, selected]); | |
| 210 | }, [groups, navigate, projectKey, members.length, environmentId]); | |
| 291 | 211 | |
| 292 | 212 | if (!selected) { |
| 293 | 213 | return ( |
| ... | ... | @@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { |
| 298 | 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 | 389 | const navigate = useNavigate(); |
| 306 | 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 | 396 | const representative = |
| 308 | 397 | group.memberProjects.find( |
| 309 | (member) => member.environmentId === group.environmentId && member.id === group.id, | |
| 398 | (member) => environmentById.get(member.environmentId)?.serverConfig != null, | |
| 310 | 399 | ) ?? group.memberProjects[0]!; |
| 311 | const settings = usePrimarySettings(); | |
| 312 | 400 | // Provider instances and model options belong to the environment that runs |
| 313 | 401 | // the project's threads. The hosted app has no primary environment, so |
| 314 | 402 | // reading them from there would show "No providers available" everywhere. |
| ... | ... | @@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 320 | 408 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 321 | 409 | const threads = useThreadShells(); |
| 322 | 410 | const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); |
| 323 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); | |
| 324 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { | |
| 325 | reportFailure: false, | |
| 326 | }); | |
| 327 | const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { | |
| 328 | reportFailure: false, | |
| 411 | const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); | |
| 412 | const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); | |
| 413 | const savingBrowserAccessRef = useRef(false); | |
| 414 | const browserOverrides = group.memberProjects.map( | |
| 415 | (member) => | |
| 416 | environmentById.get(member.environmentId)?.serverConfig?.settings | |
| 417 | .projectAgentBrowserAccessOverrides[member.id], | |
| 418 | ); | |
| 419 | const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; | |
| 420 | const browserMixed = group.memberProjects.some((member, index) => { | |
| 421 | const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; | |
| 422 | if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; | |
| 423 | return ( | |
| 424 | browserOverrides[index] !== browserOverride || | |
| 425 | (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== | |
| 426 | (browserOverride ?? projectSettings.enableAgentBrowserAccess) | |
| 427 | ); | |
| 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 | 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 | 484 | const faviconPath = representative.faviconPath ?? null; |
| 347 | 485 | const projectIcon = representative.projectIcon ?? null; |
| ... | ... | @@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 355 | 493 | ? window.desktopBridge?.pickProjectFavicon |
| 356 | 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 | 496 | const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => { |
| 367 | 497 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; |
| 368 | 498 | const error = squashAtomCommandFailure(result); |
| ... | ... | @@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 437 | 567 | |
| 438 | 568 | // ----- default model ----- |
| 439 | 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 | 589 | const resolvedInstanceId = resolvedSelection?.instanceId ?? null; |
| 442 | 590 | const resolvedModel = resolvedSelection?.model ?? null; |
| 443 | 591 | const instanceEntries = useMemo( |
| ... | ... | @@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 461 | 609 | [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], |
| 462 | 610 | ); |
| 463 | 611 | const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); |
| 464 | const setDefaultModel = useCallback( | |
| 465 | (selection: ModelSelection | null) => | |
| 466 | void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), | |
| 467 | [updateAllMembers], | |
| 468 | ); | |
| 612 | const setDefaultModel = (selection: ModelSelection | null) => { | |
| 613 | if (selection !== null) { | |
| 614 | for (const member of group.memberProjects) { | |
| 615 | const environment = environmentById.get(member.environmentId); | |
| 616 | const config = environment?.serverConfig; | |
| 617 | const entry = config | |
| 618 | ? applyProviderInstanceSettings( | |
| 619 | deriveProviderInstanceEntries(config.providers), | |
| 620 | config.settings, | |
| 621 | ).find((candidate) => candidate.instanceId === selection.instanceId) | |
| 622 | : undefined; | |
| 623 | const options = config | |
| 624 | ? getCustomModelOptionsByInstance( | |
| 625 | { ...projectSettings, ...config.settings }, | |
| 626 | config.providers, | |
| 627 | ).get(selection.instanceId) | |
| 628 | : undefined; | |
| 629 | if ( | |
| 630 | !entry?.enabled || | |
| 631 | !entry.isAvailable || | |
| 632 | !options?.some((model) => model.slug === selection.model && !model.isUnavailable) | |
| 633 | ) { | |
| 634 | toastManager.add({ | |
| 635 | type: "warning", | |
| 636 | title: "Project model not saved", | |
| 637 | description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, | |
| 638 | }); | |
| 639 | return; | |
| 640 | } | |
| 641 | } | |
| 642 | } | |
| 643 | void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); | |
| 644 | }; | |
| 469 | 645 | |
| 470 | 646 | // ----- new-thread workspace mode ----- |
| 471 | 647 | const storedEnvMode = representative.defaultThreadEnvMode ?? null; |
| 648 | const mixedWorkspace = group.memberProjects.some( | |
| 649 | (member) => member.defaultThreadEnvMode !== storedEnvMode, | |
| 650 | ); | |
| 472 | 651 | const setDefaultThreadEnvMode = useCallback( |
| 473 | 652 | (mode: ThreadEnvMode | null) => |
| 474 | 653 | void updateAllMembers( |
| ... | ... | @@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 478 | 657 | [updateAllMembers], |
| 479 | 658 | ); |
| 480 | 659 | |
| 481 | const autoPull = representative.autoPull ?? false; | |
| 482 | const setAutoPull = useCallback( | |
| 483 | (enabled: boolean) => | |
| 484 | void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), | |
| 485 | [updateAllMembers], | |
| 660 | const autoPull = resolveProjectAutoPull( | |
| 661 | projectSettings, | |
| 662 | representative.id, | |
| 663 | representative.autoPull, | |
| 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 | 679 | // ----- project icon ----- |
| 489 | 680 | const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); |
| ... | ... | @@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 506 | 697 | ); |
| 507 | 698 | |
| 508 | 699 | // ----- checkout selection and scripts ----- |
| 509 | const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); | |
| 510 | const selectedCheckout = | |
| 511 | group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? | |
| 512 | representative; | |
| 700 | const hasMultipleCheckouts = group.memberProjects.length > 1; | |
| 701 | const [selectedCheckoutKey, setSelectedCheckoutKey] = useState<string | null>(null); | |
| 702 | const selectedCheckoutMatch = group.memberProjects.find( | |
| 703 | (member) => member.physicalProjectKey === selectedCheckoutKey, | |
| 704 | ); | |
| 705 | const selectedCheckout = selectedCheckoutMatch ?? representative; | |
| 513 | 706 | const selectedServerConfig = useAtomValue( |
| 514 | 707 | serverEnvironment.configValueAtom(selectedCheckout.environmentId), |
| 515 | 708 | ); |
| 516 | 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 | 713 | const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null); |
| 519 | // Script writes replace the whole array, so two overlapping writes computed | |
| 520 | // from the same snapshot would drop each other's changes. One at a time. | |
| 521 | const [isSavingScripts, setIsSavingScripts] = useState(false); | |
| 522 | const savingScriptsRef = useRef(false); | |
| 714 | const { | |
| 715 | saving: isSavingScripts, | |
| 716 | persist: persistScripts, | |
| 717 | submit: submitScript, | |
| 718 | } = useProjectScriptSettings([ | |
| 719 | { | |
| 720 | environmentId: selectedCheckout.environmentId, | |
| 721 | settings: scriptSettings, | |
| 722 | keybindings, | |
| 723 | project: selectedCheckout, | |
| 724 | }, | |
| 725 | ]); | |
| 523 | 726 | const t3File = useT3ProjectFileState( |
| 524 | 727 | selectedCheckout.environmentId, |
| 525 | 728 | selectedCheckout.workspaceRoot, |
| 526 | 729 | ); |
| 527 | 730 | // What the "Default" option resolves to while no override is set: the |
| 528 | 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 | 733 | const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; |
| 531 | 734 | const importableScripts = useMemo( |
| 532 | 735 | () => |
| ... | ... | @@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 541 | 744 | [scripts, t3File.scripts], |
| 542 | 745 | ); |
| 543 | 746 | |
| 544 | const persistScripts = useCallback( | |
| 545 | async ( | |
| 546 | nextScripts: ReadonlyArray<ReturnType<typeof buildProjectScript>>, | |
| 547 | keybinding: string | null | undefined, | |
| 548 | keybindingCommand: ReturnType<typeof commandForProjectScript>, | |
| 549 | ): Promise<AtomCommandResult<void, unknown>> => { | |
| 550 | if (savingScriptsRef.current) { | |
| 551 | return AsyncResult.failure( | |
| 552 | Cause.fail(new Error("Another script change is still saving. Try again.")), | |
| 553 | ); | |
| 554 | } | |
| 555 | savingScriptsRef.current = true; | |
| 556 | setIsSavingScripts(true); | |
| 557 | try { | |
| 558 | // Captured before the write so a cleared or deleted binding can be | |
| 559 | // removed from the keybindings config afterwards. | |
| 560 | const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); | |
| 561 | const updateResult = mapAtomCommandResult( | |
| 562 | await updateProject({ | |
| 563 | environmentId: selectedCheckout.environmentId, | |
| 564 | input: { projectId: selectedCheckout.id, scripts: nextScripts }, | |
| 565 | }), | |
| 566 | () => undefined, | |
| 567 | ); | |
| 568 | if (updateResult._tag === "Failure") { | |
| 569 | reportFailure("Failed to save scripts", updateResult); | |
| 570 | return updateResult; | |
| 571 | } | |
| 572 | ||
| 573 | const keybindingRule = decodeProjectScriptKeybindingRule({ | |
| 574 | keybinding, | |
| 575 | command: keybindingCommand, | |
| 576 | }); | |
| 577 | if (!isElectron) return updateResult; | |
| 578 | const environmentIds = [selectedCheckout.environmentId]; | |
| 579 | const previousTarget = previousKeybinding | |
| 580 | ? decodeProjectScriptKeybindingRule({ | |
| 581 | keybinding: previousKeybinding, | |
| 582 | command: keybindingCommand, | |
| 583 | }) | |
| 584 | : null; | |
| 585 | if (keybindingRule) { | |
| 586 | // `replace` swaps the command's previous rule instead of appending a | |
| 587 | // second one that would keep the old shortcut alive. | |
| 588 | const input = | |
| 589 | previousTarget && previousTarget.key !== keybindingRule.key | |
| 590 | ? { ...keybindingRule, replace: previousTarget } | |
| 591 | : keybindingRule; | |
| 592 | for (const environmentId of environmentIds) { | |
| 593 | const result = mapAtomCommandResult( | |
| 594 | await upsertKeybinding({ environmentId, input }), | |
| 595 | () => undefined, | |
| 596 | ); | |
| 597 | if (result._tag === "Failure") { | |
| 598 | reportFailure("Failed to save keybinding", result); | |
| 599 | return result; | |
| 600 | } | |
| 601 | } | |
| 602 | } else if (previousTarget) { | |
| 603 | for (const environmentId of environmentIds) { | |
| 604 | const result = mapAtomCommandResult( | |
| 605 | await removeKeybinding({ environmentId, input: previousTarget }), | |
| 606 | () => undefined, | |
| 607 | ); | |
| 608 | if (result._tag === "Failure") { | |
| 609 | reportFailure("Failed to remove keybinding", result); | |
| 610 | return result; | |
| 611 | } | |
| 612 | } | |
| 613 | } | |
| 614 | return updateResult; | |
| 615 | } finally { | |
| 616 | savingScriptsRef.current = false; | |
| 617 | setIsSavingScripts(false); | |
| 618 | } | |
| 619 | }, | |
| 620 | [ | |
| 621 | keybindings, | |
| 622 | removeKeybinding, | |
| 623 | reportFailure, | |
| 624 | selectedCheckout.environmentId, | |
| 625 | selectedCheckout.id, | |
| 626 | updateProject, | |
| 627 | upsertKeybinding, | |
| 628 | ], | |
| 629 | ); | |
| 630 | ||
| 631 | const submitScript = useCallback( | |
| 632 | async ( | |
| 633 | scriptId: string | null, | |
| 634 | input: NewProjectScriptInput, | |
| 635 | ): Promise<AtomCommandResult<void, unknown>> => { | |
| 636 | if (scriptId === null) { | |
| 637 | const nextId = nextProjectScriptId( | |
| 638 | input.name, | |
| 639 | scripts.map((script) => script.id), | |
| 640 | ); | |
| 641 | const nextScript = buildProjectScript(nextId, input); | |
| 642 | const nextScripts = input.runOnWorktreeCreate | |
| 643 | ? [ | |
| 644 | ...scripts.map((script) => | |
| 645 | script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, | |
| 646 | ), | |
| 647 | nextScript, | |
| 648 | ] | |
| 649 | : [...scripts, nextScript]; | |
| 650 | return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); | |
| 651 | } | |
| 652 | ||
| 653 | const updatedScript = buildProjectScript(scriptId, input); | |
| 654 | const nextScripts = scripts.map((script) => | |
| 655 | script.id === scriptId | |
| 656 | ? updatedScript | |
| 657 | : input.runOnWorktreeCreate | |
| 658 | ? { ...script, runOnWorktreeCreate: false } | |
| 659 | : script, | |
| 660 | ); | |
| 661 | return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); | |
| 662 | }, | |
| 663 | [persistScripts, scripts], | |
| 664 | ); | |
| 665 | ||
| 666 | const deleteScript = useCallback( | |
| 667 | (scriptId: string) => { | |
| 668 | const nextScripts = scripts.filter((script) => script.id !== scriptId); | |
| 669 | void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); | |
| 670 | }, | |
| 671 | [persistScripts, scripts], | |
| 672 | ); | |
| 747 | const deleteScript = (scriptId: string) => | |
| 748 | void persistScripts( | |
| 749 | (current) => current.filter((script) => script.id !== scriptId), | |
| 750 | scriptId, | |
| 751 | null, | |
| 752 | ); | |
| 673 | 753 | |
| 674 | 754 | const importFileScript = useCallback( |
| 675 | 755 | async (fileScript: T3ProjectFileScript) => { |
| ... | ... | @@ -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 | 778 | // ----- checkouts ----- |
| ... | ... | @@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 720 | 800 | memberKeys.has(`${thread.environmentId}:${thread.projectId}`), |
| 721 | 801 | ); |
| 722 | 802 | const isWholeGroup = members.length === group.memberProjects.length; |
| 803 | const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; | |
| 723 | 804 | const singleMember = members.length === 1 ? members[0]! : null; |
| 724 | 805 | const targetLabel = singleMember?.title ?? group.displayName; |
| 725 | 806 | const confirmed = await settlePromise(() => |
| 726 | 807 | api.dialogs.confirm( |
| 727 | 808 | [ |
| 728 | 809 | projectThreads.length > 0 |
| 729 | ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` | |
| 730 | : `Remove project "${targetLabel}"?`, | |
| 810 | ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` | |
| 811 | : `Remove ${targetKind} "${targetLabel}"?`, | |
| 731 | 812 | ...(singleMember |
| 732 | 813 | ? [ |
| 733 | 814 | `Path: ${singleMember.workspaceRoot}`, |
| ... | ... | @@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 741 | 822 | "This permanently clears conversation history for those threads and any archived threads.", |
| 742 | 823 | ] |
| 743 | 824 | : ["This permanently clears any archived conversation history."]), |
| 744 | isWholeGroup | |
| 825 | isWholeGroup && !hasOtherMembers | |
| 745 | 826 | ? "This removes only the project entries, not the files on disk." |
| 746 | 827 | : "Other entries in this grouped project are unaffected.", |
| 747 | 828 | "This action cannot be undone.", |
| ... | ... | @@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 783 | 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 | 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 | 880 | deleteProject, |
| 794 | 881 | group.displayName, |
| 795 | 882 | group.memberProjects.length, |
| 883 | group.projectKey, | |
| 884 | hasOtherMembers, | |
| 796 | 885 | navigate, |
| 797 | 886 | reportFailure, |
| 798 | 887 | threads, |
| 799 | 888 | ], |
| 800 | 889 | ); |
| 801 | 890 | |
| 802 | const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; | |
| 803 | 891 | const selectedCheckoutGrouping = |
| 804 | 892 | projectGroupingSettings.sidebarProjectGroupingOverrides?.[ |
| 805 | 893 | deriveProjectGroupingOverrideKey(selectedCheckout) |
| 806 | 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 | 907 | return ( |
| 810 | 908 | <> |
| 811 | <SettingsPageContainer width="wide" className="gap-8"> | |
| 812 | <SettingsSection title="Project"> | |
| 909 | <SettingsPageContainer className="gap-6"> | |
| 910 | <SettingsSection title="Project" hideTitle> | |
| 813 | 911 | <SettingsRow |
| 814 | 912 | title="Name" |
| 815 | 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 | 985 | /> |
| 888 | 986 | <SettingsRow |
| 889 | 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 | 1000 | resetAction={ |
| 892 | storedSelection !== null ? ( | |
| 1001 | group.memberProjects.some((member) => member.defaultModelSelection !== null) ? ( | |
| 893 | 1002 | <SettingResetButton |
| 894 | 1003 | label="project default model" |
| 1004 | tooltip="Reset to inherited model" | |
| 895 | 1005 | onClick={() => setDefaultModel(null)} |
| 896 | 1006 | /> |
| 897 | 1007 | ) : null |
| ... | ... | @@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 946 | 1056 | /> |
| 947 | 1057 | <SettingsRow |
| 948 | 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 | 1071 | resetAction={ |
| 951 | storedEnvMode !== null ? ( | |
| 1072 | group.memberProjects.some((member) => member.defaultThreadEnvMode !== null) ? ( | |
| 952 | 1073 | <SettingResetButton |
| 953 | 1074 | label="project workspace default" |
| 1075 | tooltip="Reset to inherited workspace" | |
| 954 | 1076 | onClick={() => setDefaultThreadEnvMode(null)} |
| 955 | 1077 | /> |
| 956 | 1078 | ) : null |
| ... | ... | @@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 990 | 1112 | <SettingsRow |
| 991 | 1113 | title="Automatically pull" |
| 992 | 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 | 1122 | resetAction={ |
| 994 | autoPull ? ( | |
| 995 | <SettingResetButton label="automatic pull" onClick={() => setAutoPull(false)} /> | |
| 1123 | autoPullOverridden ? ( | |
| 1124 | <SettingResetButton | |
| 1125 | label="automatic pull" | |
| 1126 | tooltip="Reset to inherited automatic pull setting" | |
| 1127 | disabled={savingBrowserAccess} | |
| 1128 | onClick={() => void setAutoPull(undefined)} | |
| 1129 | /> | |
| 996 | 1130 | ) : null |
| 997 | 1131 | } |
| 998 | 1132 | control={ |
| 999 | 1133 | <Switch |
| 1000 | 1134 | checked={autoPull} |
| 1135 | disabled={savingBrowserAccess} | |
| 1001 | 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 | 1197 | </SettingsSection> |
| 1007 | 1198 | |
| 1008 | <SettingsSection | |
| 1009 | title="Checkout" | |
| 1010 | headerAction={ | |
| 1011 | <Select | |
| 1012 | value={selectedCheckout.physicalProjectKey} | |
| 1013 | onValueChange={(value) => setSelectedCheckoutKey(String(value))} | |
| 1014 | > | |
| 1015 | <SelectTrigger size="sm" className="max-w-64" aria-label="Selected checkout"> | |
| 1016 | <SelectValue>{selectedCheckoutLabel}</SelectValue> | |
| 1017 | </SelectTrigger> | |
| 1018 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 1019 | {group.memberProjects.map((member) => ( | |
| 1020 | <SelectItem | |
| 1021 | key={member.physicalProjectKey} | |
| 1022 | hideIndicator | |
| 1023 | value={member.physicalProjectKey} | |
| 1024 | > | |
| 1025 | {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} | |
| 1026 | </SelectItem> | |
| 1027 | ))} | |
| 1028 | </SelectPopup> | |
| 1029 | </Select> | |
| 1030 | } | |
| 1031 | > | |
| 1032 | <div className="px-3 py-2 sm:px-4"> | |
| 1033 | <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm"> | |
| 1034 | <Tooltip> | |
| 1035 | <TooltipTrigger | |
| 1036 | render={ | |
| 1037 | <button | |
| 1038 | aria-label="Copy checkout path" | |
| 1039 | className="group flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left outline-none hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 1040 | type="button" | |
| 1041 | onClick={() => | |
| 1042 | copyPathToClipboard(selectedCheckout.workspaceRoot, { | |
| 1043 | path: selectedCheckout.workspaceRoot, | |
| 1044 | }) | |
| 1045 | } | |
| 1046 | > | |
| 1047 | <code className="min-w-0 flex-1 truncate font-mono"> | |
| 1048 | {selectedCheckout.workspaceRoot} | |
| 1049 | </code> | |
| 1050 | <CopyIcon className="size-4 shrink-0 opacity-60 group-hover:opacity-100" /> | |
| 1051 | </button> | |
| 1052 | } | |
| 1053 | /> | |
| 1054 | <TooltipPopup side="top">Copy path</TooltipPopup> | |
| 1055 | </Tooltip> | |
| 1056 | <div className="shrink-0 border-l border-border/60 px-2 tabular-nums"> | |
| 1057 | {selectedCheckoutThreadCount === 1 | |
| 1058 | ? "1 thread" | |
| 1059 | : `${selectedCheckoutThreadCount} threads`} | |
| 1060 | </div> | |
| 1061 | </div> | |
| 1062 | </div> | |
| 1199 | <SettingsSection title="Checkout"> | |
| 1200 | {hasMultipleCheckouts ? ( | |
| 1201 | <SettingsRow | |
| 1202 | title="Checkout" | |
| 1203 | description="Actions and grouping belong to this checkout." | |
| 1204 | control={ | |
| 1205 | <Select | |
| 1206 | value={selectedCheckout.physicalProjectKey} | |
| 1207 | onValueChange={(value) => { | |
| 1208 | if (value) setSelectedCheckoutKey(value); | |
| 1209 | }} | |
| 1210 | > | |
| 1211 | <SelectTrigger size="sm" aria-label="Checkout"> | |
| 1212 | <SelectValue className="max-w-96 truncate">{selectedCheckoutLabel}</SelectValue> | |
| 1213 | </SelectTrigger> | |
| 1214 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 1215 | {group.memberProjects.map((member) => ( | |
| 1216 | <SelectItem key={member.physicalProjectKey} value={member.physicalProjectKey}> | |
| 1217 | <span className="max-w-96 whitespace-normal break-all"> | |
| 1218 | {checkoutLabel(member)} | |
| 1219 | </span> | |
| 1220 | </SelectItem> | |
| 1221 | ))} | |
| 1222 | </SelectPopup> | |
| 1223 | </Select> | |
| 1224 | } | |
| 1225 | /> | |
| 1226 | ) : null} | |
| 1063 | 1227 | <SettingsRow |
| 1064 | 1228 | title="Project grouping" |
| 1065 | 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 | 1239 | control={ |
| 1067 | 1240 | <Select |
| 1068 | 1241 | value={selectedCheckoutGrouping} |
| ... | ... | @@ -1121,10 +1294,20 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 1121 | 1294 | <div className="min-w-0"> |
| 1122 | 1295 | <h3 className="text-base font-semibold text-foreground">Actions</h3> |
| 1123 | 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 | 1300 | </p> |
| 1126 | 1301 | </div> |
| 1127 | 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 | 1311 | {importableScripts.length > 0 ? ( |
| 1129 | 1312 | <Menu> |
| 1130 | 1313 | <MenuTrigger |
| ... | ... | @@ -1173,65 +1356,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 1173 | 1356 | </Button> |
| 1174 | 1357 | </div> |
| 1175 | 1358 | </div> |
| 1176 | {scripts.length === 0 ? ( | |
| 1177 | <p className="px-3 py-2 text-base text-muted-foreground sm:px-4 sm:text-sm"> | |
| 1178 | No actions configured for this checkout. | |
| 1179 | </p> | |
| 1180 | ) : ( | |
| 1181 | scripts.map((script) => { | |
| 1182 | const shortcutLabel = shortcutLabelForCommand( | |
| 1183 | keybindings, | |
| 1184 | commandForProjectScript(script.id), | |
| 1185 | ); | |
| 1186 | return ( | |
| 1187 | <SettingsRow | |
| 1188 | key={script.id} | |
| 1189 | className="group py-2" | |
| 1190 | title={ | |
| 1191 | <span className="flex min-w-0 items-center gap-2"> | |
| 1192 | <ScriptIcon | |
| 1193 | icon={script.icon} | |
| 1194 | className="size-4 shrink-0 text-muted-foreground" | |
| 1195 | /> | |
| 1196 | <span className="min-w-0 truncate">{script.name}</span> | |
| 1197 | {script.runOnWorktreeCreate ? ( | |
| 1198 | <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground"> | |
| 1199 | setup | |
| 1200 | </span> | |
| 1201 | ) : null} | |
| 1202 | {script.previewUrl ? ( | |
| 1203 | <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-[11px] font-normal text-muted-foreground max-sm:hidden"> | |
| 1204 | preview · desktop only | |
| 1205 | </span> | |
| 1206 | ) : null} | |
| 1207 | </span> | |
| 1208 | } | |
| 1209 | description={ | |
| 1210 | <code className="block max-w-full truncate font-mono">{script.command}</code> | |
| 1211 | } | |
| 1212 | control={ | |
| 1213 | <> | |
| 1214 | {shortcutLabel ? ( | |
| 1215 | <span className="text-xs text-muted-foreground">{shortcutLabel}</span> | |
| 1216 | ) : null} | |
| 1217 | <Button | |
| 1218 | size="icon-xs" | |
| 1219 | variant="ghost" | |
| 1220 | className="shrink-0 text-muted-foreground opacity-0 group-focus-within:opacity-100 group-hover:opacity-100" | |
| 1221 | aria-label={`Edit ${script.name}`} | |
| 1222 | disabled={isSavingScripts} | |
| 1223 | onClick={() => | |
| 1224 | setEditorRequest(editorRequestForScript(script, keybindings)) | |
| 1225 | } | |
| 1226 | > | |
| 1227 | <SettingsIcon className="size-3.5" /> | |
| 1228 | </Button> | |
| 1229 | </> | |
| 1230 | } | |
| 1231 | /> | |
| 1232 | ); | |
| 1233 | }) | |
| 1234 | )} | |
| 1359 | <ProjectActionsList | |
| 1360 | scripts={scripts} | |
| 1361 | keybindings={keybindings} | |
| 1362 | disabled={isSavingScripts} | |
| 1363 | onEdit={(script) => setEditorRequest(editorRequestForScript(script, keybindings))} | |
| 1364 | /> | |
| 1235 | 1365 | {t3File.status === "invalid" ? ( |
| 1236 | 1366 | <SettingsRow |
| 1237 | 1367 | title="t3.json is invalid" |
| ... | ... | @@ -1244,12 +1374,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 1244 | 1374 | <SettingsSection title="Danger"> |
| 1245 | 1375 | <SettingsRow |
| 1246 | 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 | 1383 | description={ |
| 1250 | group.memberProjects.length > 1 | |
| 1251 | ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` | |
| 1252 | : "Deletes the project entry and its threads. Files on disk are not touched." | |
| 1384 | hasOtherMembers | |
| 1385 | ? "Deletes the selected machine's checkout entries and their threads. Other machines and files on disk are not touched." | |
| 1386 | : group.memberProjects.length > 1 | |
| 1387 | ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` | |
| 1388 | : "Deletes the project entry and its threads. Files on disk are not touched." | |
| 1253 | 1389 | } |
| 1254 | 1390 | control={ |
| 1255 | 1391 | <Button |
| ... | ... | @@ -1258,7 +1394,11 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 1258 | 1394 | onClick={() => void removeMembers(group.memberProjects)} |
| 1259 | 1395 | > |
| 1260 | 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 | 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 | 2557 | |
| 2558 | 2558 | <SettingsSection id="projects-and-threads" title="Projects & threads"> |
| 2559 | 2559 | <SettingsRow |
| 2560 | serverScoped | |
| 2561 | 2560 | {...searchableSetting("new-threads")} |
| 2562 | description="Pick the default workspace mode for newly created draft threads." | |
| 2563 | resetAction={ | |
| 2564 | settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode || | |
| 2565 | settings.newWorktreesStartFromOrigin !== | |
| 2566 | DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ( | |
| 2567 | <SettingResetButton | |
| 2568 | label="new threads" | |
| 2569 | onClick={() => | |
| 2570 | updateSettings({ | |
| 2571 | defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, | |
| 2572 | newWorktreesStartFromOrigin: | |
| 2573 | DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, | |
| 2574 | }) | |
| 2575 | } | |
| 2576 | /> | |
| 2577 | ) : null | |
| 2578 | } | |
| 2561 | description="Choose the default model and workspace for all projects or a specific project." | |
| 2579 | 2562 | control={ |
| 2580 | <Select | |
| 2581 | value={settings.defaultThreadEnvMode} | |
| 2582 | onValueChange={(value) => { | |
| 2583 | if (value === "local" || value === "worktree") { | |
| 2584 | updateSettings({ defaultThreadEnvMode: value }); | |
| 2585 | } | |
| 2586 | }} | |
| 2563 | <Button | |
| 2564 | render={ | |
| 2565 | <Link to="/settings/projects" search={{ project: undefined, machine: undefined }} /> | |
| 2566 | } | |
| 2567 | size="sm" | |
| 2568 | variant="outline" | |
| 2587 | 2569 | > |
| 2588 | <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Default thread mode"> | |
| 2589 | <SelectValue> | |
| 2590 | {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"} | |
| 2591 | </SelectValue> | |
| 2592 | </SelectTrigger> | |
| 2593 | <SelectPopup align="end" alignItemWithTrigger={false}> | |
| 2594 | <SelectItem hideIndicator value="local"> | |
| 2595 | Local | |
| 2596 | </SelectItem> | |
| 2597 | <SelectItem hideIndicator value="worktree"> | |
| 2598 | New worktree | |
| 2599 | </SelectItem> | |
| 2600 | </SelectPopup> | |
| 2601 | </Select> | |
| 2570 | Project settings | |
| 2571 | </Button> | |
| 2602 | 2572 | } |
| 2603 | 2573 | /> |
| 2604 | 2574 | |
| 2605 | 2575 | <SettingsRow |
| 2606 | 2576 | serverScoped |
| 2607 | className="bg-muted/20 sm:pl-9" | |
| 2608 | title={searchableSetting("start-from-origin").title} | |
| 2577 | {...searchableSetting("start-from-origin")} | |
| 2609 | 2578 | description="Creates the worktree from the latest matching branch on origin instead of your local branch." |
| 2610 | 2579 | resetAction={ |
| 2611 | 2580 | settings.newWorktreesStartFromOrigin !== |
apps/web/src/components/settings/SettingsSidebarNav.tsx+9-1| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | BlocksIcon, |
| 16 | 16 | BotIcon, |
| 17 | 17 | GitBranchIcon, |
| 18 | PanelsTopLeftIcon, | |
| 18 | 19 | KeyboardIcon, |
| 19 | 20 | Link2Icon, |
| 20 | 21 | PaletteIcon, |
| ... | ... | @@ -72,6 +73,7 @@ const SETTINGS_SECTION_ICONS: Readonly< |
| 72 | 73 | > = { |
| 73 | 74 | "/settings/general": Settings2Icon, |
| 74 | 75 | "/settings/appearance": PaletteIcon, |
| 76 | "/settings/projects": PanelsTopLeftIcon, | |
| 75 | 77 | "/settings/keybindings": KeyboardIcon, |
| 76 | 78 | "/settings/providers": BotIcon, |
| 77 | 79 | "/settings/integrations": BlocksIcon, |
| ... | ... | @@ -274,12 +276,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 274 | 276 | setOpenMobile(false); |
| 275 | 277 | } |
| 276 | 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 | 284 | scrollToSettingsTarget(targetId); |
| 279 | 285 | return; |
| 280 | 286 | } |
| 281 | 287 | void navigate({ |
| 282 | 288 | to: item.to, |
| 289 | search: (previous) => | |
| 290 | item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, | |
| 283 | 291 | hash: targetId, |
| 284 | 292 | replace: true, |
| 285 | 293 | hashScrollIntoView: false, |
apps/web/src/components/settings/settingsLayout.tsx+8-2| ... | ... | @@ -284,7 +284,11 @@ export function SettingsRow({ |
| 284 | 284 | ref={targetRef} |
| 285 | 285 | tabIndex={rowProps.id ? -1 : rowProps.tabIndex} |
| 286 | 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 | 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 | 294 | <div className="min-w-0 flex-1 space-y-1"> |
| ... | ... | @@ -320,10 +324,12 @@ export function SettingsRow({ |
| 320 | 324 | |
| 321 | 325 | export function SettingResetButton({ |
| 322 | 326 | label, |
| 327 | tooltip = "Reset to default", | |
| 323 | 328 | disabled = false, |
| 324 | 329 | onClick, |
| 325 | 330 | }: { |
| 326 | 331 | label: string; |
| 332 | tooltip?: string; | |
| 327 | 333 | disabled?: boolean; |
| 328 | 334 | onClick: () => void; |
| 329 | 335 | }) { |
| ... | ... | @@ -345,7 +351,7 @@ export function SettingResetButton({ |
| 345 | 351 | </Button> |
| 346 | 352 | } |
| 347 | 353 | /> |
| 348 | <TooltipPopup side="top">Reset to default</TooltipPopup> | |
| 354 | <TooltipPopup side="top">{tooltip}</TooltipPopup> | |
| 349 | 355 | </Tooltip> |
| 350 | 356 | ); |
| 351 | 357 | } |
apps/web/src/components/settings/settingsSearch.ts+12-3| ... | ... | @@ -2,6 +2,7 @@ import { isElectron } from "~/env"; |
| 2 | 2 | import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; |
| 3 | 3 | |
| 4 | 4 | export type SettingsPath = |
| 5 | | "/settings/projects" | |
| 5 | 6 | | "/settings/general" |
| 6 | 7 | | "/settings/appearance" |
| 7 | 8 | | "/settings/keybindings" |
| ... | ... | @@ -49,6 +50,7 @@ export interface SettingsSearchAvailability { |
| 49 | 50 | export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 50 | 51 | "/settings/general": "General", |
| 51 | 52 | "/settings/appearance": "Appearance", |
| 53 | "/settings/projects": "Projects", | |
| 52 | 54 | "/settings/keybindings": "Keybindings", |
| 53 | 55 | "/settings/providers": "Providers", |
| 54 | 56 | "/settings/integrations": "Integrations", |
| ... | ... | @@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { |
| 63 | 65 | * that may not be mounted point at their nearest stable section instead. |
| 64 | 66 | */ |
| 65 | 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 | 77 | id: "color-scheme", |
| 68 | 78 | title: "Color scheme", |
| ... | ... | @@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 235 | 245 | { |
| 236 | 246 | id: "new-threads", |
| 237 | 247 | title: "New threads", |
| 238 | to: "/settings/general", | |
| 248 | to: "/settings/projects", | |
| 239 | 249 | searchTerms: ["default workspace mode draft local worktree"], |
| 240 | 250 | }, |
| 241 | 251 | { |
| 242 | 252 | id: "start-from-origin", |
| 243 | 253 | title: "Start from origin", |
| 244 | 254 | to: "/settings/general", |
| 245 | targetId: "new-threads", | |
| 246 | 255 | searchTerms: ["new worktrees latest matching remote branch local"], |
| 247 | 256 | }, |
| 248 | 257 | { |
| ... | ... | @@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 345 | 354 | { |
| 346 | 355 | id: "agent-browser-access", |
| 347 | 356 | title: "Agent browser access", |
| 348 | to: "/settings/integrations", | |
| 357 | to: "/settings/projects", | |
| 349 | 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 | 49 | }); |
| 50 | 50 | |
| 51 | 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 | 68 | vi.mock("@t3tools/client-runtime/environment", () => ({ |
| 55 | 69 | scopedProjectKey: () => "remote-project", |
| 56 | 70 | scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), |
| 57 | 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 | 77 | vi.mock("@t3tools/shared/threadEnvMode", () => ({ |
| 61 | 78 | resolveDefaultThreadEnvMode: (input: { |
| 62 | 79 | readonly projectFile: "local" | "worktree" | null; |
| ... | ... | @@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({ |
| 113 | 130 | useProjects: () => [], |
| 114 | 131 | useThread: () => null, |
| 115 | 132 | })); |
| 116 | vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); | |
| 133 | vi.mock("../state/server", () => ({ | |
| 134 | environmentServerConfigsAtom: {}, | |
| 135 | primaryServerSettingsAtom: "primary-settings", | |
| 136 | })); | |
| 117 | 137 | vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); |
| 118 | 138 | vi.mock("../uiStateStore", () => ({ |
| 119 | 139 | legacyProjectCwdPreferenceKey: () => "remote-project", |
apps/web/src/hooks/useHandleNewThread.ts+20-10| ... | ... | @@ -4,7 +4,12 @@ import { |
| 4 | 4 | scopeProjectRef, |
| 5 | 5 | scopeThreadRef, |
| 6 | 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 | 13 | import { useParams, useRouter } from "@tanstack/react-router"; |
| 9 | 14 | import { useCallback, useMemo } from "react"; |
| 10 | 15 | import { |
| ... | ... | @@ -30,7 +35,7 @@ import { |
| 30 | 35 | resolveNewThreadModelSelectionOverride, |
| 31 | 36 | } from "../lib/chatThreadActions"; |
| 32 | 37 | import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; |
| 33 | import { primaryServerSettingsAtom } from "../state/server"; | |
| 38 | import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; | |
| 34 | 39 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| 35 | 40 | import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; |
| 36 | 41 | import { useClientSettings } from "./useSettings"; |
| ... | ... | @@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef |
| 55 | 60 | } |
| 56 | 61 | |
| 57 | 62 | export function useNewThreadHandler() { |
| 58 | // New-thread defaults are a user preference, and the settings UI only ever | |
| 59 | // edits the primary environment's settings.json. Reading the target | |
| 60 | // environment's own settings here would silently reset remote projects to | |
| 61 | // the decoded defaults ("local" mode, current branch), since nothing can | |
| 62 | // set those values on a remote server. | |
| 63 | const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); | |
| 63 | 64 | const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); |
| 64 | 65 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 65 | 66 | const router = useRouter(); |
| ... | ... | @@ -83,6 +84,8 @@ export function useNewThreadHandler() { |
| 83 | 84 | // up again and finding whichever draft it happens to hold. |
| 84 | 85 | ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { |
| 85 | 86 | const projects = readProjects(); |
| 87 | const targetServerSettings = | |
| 88 | environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; | |
| 86 | 89 | const { |
| 87 | 90 | getComposerDraft, |
| 88 | 91 | getDraftSessionByLogicalProjectKey, |
| ... | ... | @@ -138,7 +141,8 @@ export function useNewThreadHandler() { |
| 138 | 141 | ); |
| 139 | 142 | const resolveModelSelectionOverride = (destinationDraftId: DraftId) => |
| 140 | 143 | resolveNewThreadModelSelectionOverride({ |
| 141 | projectDefaultSelection: project?.defaultModelSelection ?? null, | |
| 144 | projectDefaultSelection: | |
| 145 | project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null, | |
| 142 | 146 | carrySelection: carryModelSelection, |
| 143 | 147 | carrySourceDraftId: |
| 144 | 148 | currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, |
| ... | ... | @@ -157,7 +161,7 @@ export function useNewThreadHandler() { |
| 157 | 161 | project.workspaceRoot, |
| 158 | 162 | ) |
| 159 | 163 | : null, |
| 160 | globalDefault: primaryServerSettings.defaultThreadEnvMode, | |
| 164 | globalDefault: targetServerSettings.defaultThreadEnvMode, | |
| 161 | 165 | }); |
| 162 | 166 | }; |
| 163 | 167 | const logicalProjectKey = project |
| ... | ... | @@ -429,7 +433,13 @@ export function useNewThreadHandler() { |
| 429 | 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 | 18 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 19 | 19 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 20 | 20 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' |
| 21 | import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' | |
| 21 | 22 | import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' |
| 22 | 23 | import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' |
| 23 | 24 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| ... | ... | @@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ |
| 75 | 76 | path: '/providers', |
| 76 | 77 | getParentRoute: () => SettingsRoute, |
| 77 | 78 | } as any) |
| 79 | const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ | |
| 80 | id: '/projects', | |
| 81 | path: '/projects', | |
| 82 | getParentRoute: () => SettingsRoute, | |
| 83 | } as any) | |
| 78 | 84 | const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ |
| 79 | 85 | id: '/keybindings', |
| 80 | 86 | path: '/keybindings', |
| ... | ... | @@ -154,6 +160,7 @@ export interface FileRoutesByFullPath { |
| 154 | 160 | '/settings/general': typeof SettingsGeneralRoute |
| 155 | 161 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 156 | 162 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 163 | '/settings/projects': typeof SettingsProjectsRoute | |
| 157 | 164 | '/settings/providers': typeof SettingsProvidersRoute |
| 158 | 165 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 159 | 166 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| ... | ... | @@ -175,6 +182,7 @@ export interface FileRoutesByTo { |
| 175 | 182 | '/settings/general': typeof SettingsGeneralRoute |
| 176 | 183 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 177 | 184 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 185 | '/settings/projects': typeof SettingsProjectsRoute | |
| 178 | 186 | '/settings/providers': typeof SettingsProvidersRoute |
| 179 | 187 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 180 | 188 | '/': typeof ChatIndexRoute |
| ... | ... | @@ -199,6 +207,7 @@ export interface FileRoutesById { |
| 199 | 207 | '/settings/general': typeof SettingsGeneralRoute |
| 200 | 208 | '/settings/integrations': typeof SettingsIntegrationsRoute |
| 201 | 209 | '/settings/keybindings': typeof SettingsKeybindingsRoute |
| 210 | '/settings/projects': typeof SettingsProjectsRoute | |
| 202 | 211 | '/settings/providers': typeof SettingsProvidersRoute |
| 203 | 212 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 204 | 213 | '/_chat/': typeof ChatIndexRoute |
| ... | ... | @@ -224,6 +233,7 @@ export interface FileRouteTypes { |
| 224 | 233 | | '/settings/general' |
| 225 | 234 | | '/settings/integrations' |
| 226 | 235 | | '/settings/keybindings' |
| 236 | | '/settings/projects' | |
| 227 | 237 | | '/settings/providers' |
| 228 | 238 | | '/settings/source-control' |
| 229 | 239 | | '/$environmentId/$threadId' |
| ... | ... | @@ -245,6 +255,7 @@ export interface FileRouteTypes { |
| 245 | 255 | | '/settings/general' |
| 246 | 256 | | '/settings/integrations' |
| 247 | 257 | | '/settings/keybindings' |
| 258 | | '/settings/projects' | |
| 248 | 259 | | '/settings/providers' |
| 249 | 260 | | '/settings/source-control' |
| 250 | 261 | | '/' |
| ... | ... | @@ -268,6 +279,7 @@ export interface FileRouteTypes { |
| 268 | 279 | | '/settings/general' |
| 269 | 280 | | '/settings/integrations' |
| 270 | 281 | | '/settings/keybindings' |
| 282 | | '/settings/projects' | |
| 271 | 283 | | '/settings/providers' |
| 272 | 284 | | '/settings/source-control' |
| 273 | 285 | | '/_chat/' |
| ... | ... | @@ -351,6 +363,13 @@ declare module '@tanstack/react-router' { |
| 351 | 363 | preLoaderRoute: typeof SettingsProvidersRouteImport |
| 352 | 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 | 373 | '/settings/keybindings': { |
| 355 | 374 | id: '/settings/keybindings' |
| 356 | 375 | path: '/keybindings' |
| ... | ... | @@ -462,6 +481,7 @@ interface SettingsRouteChildren { |
| 462 | 481 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 463 | 482 | SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute |
| 464 | 483 | SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute |
| 484 | SettingsProjectsRoute: typeof SettingsProjectsRoute | |
| 465 | 485 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 466 | 486 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 467 | 487 | } |
| ... | ... | @@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { |
| 474 | 494 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 475 | 495 | SettingsIntegrationsRoute: SettingsIntegrationsRoute, |
| 476 | 496 | SettingsKeybindingsRoute: SettingsKeybindingsRoute, |
| 497 | SettingsProjectsRoute: SettingsProjectsRoute, | |
| 477 | 498 | SettingsProvidersRoute: SettingsProvidersRoute, |
| 478 | 499 | SettingsSourceControlRoute: SettingsSourceControlRoute, |
| 479 | 500 | } |
apps/web/src/routes/projects.$projectKey.tsx+6-4| ... | ... | @@ -1,15 +1,17 @@ |
| 1 | 1 | import { createFileRoute, redirect } from "@tanstack/react-router"; |
| 2 | 2 | |
| 3 | import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; | |
| 4 | ||
| 5 | 3 | export const Route = createFileRoute("/projects/$projectKey")({ |
| 6 | beforeLoad: async ({ context }) => { | |
| 4 | beforeLoad: async ({ context, params }) => { | |
| 7 | 5 | if ( |
| 8 | 6 | context.authGateState.status !== "authenticated" && |
| 9 | 7 | context.authGateState.status !== "hosted-static" |
| 10 | 8 | ) { |
| 11 | 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 | 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 | 22 | ## Project icons |
| 6 | 23 | |
| 7 | 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 | 26 | let T3 Code detect an icon again. |
| 10 | 27 | |
| 11 | 28 | ## Keep the default branch current |
packages/client-runtime/src/state/sharedSettings.test.ts+13-3| ... | ... | @@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => { |
| 44 | 44 | sidebarAutoSettleOnMerge: false, |
| 45 | 45 | continueThreadsAfterServerUpdate: true, |
| 46 | 46 | enableAgentBrowserAccess: false, |
| 47 | defaultThreadEnvMode: "worktree", | |
| 48 | newWorktreesStartFromOrigin: true, | |
| 47 | 49 | }); |
| 48 | 50 | expect(sharedPatch).toEqual({ |
| 49 | 51 | sidebarAutoSettleAfterDays: 7, |
| 50 | 52 | sidebarAutoSettleOnMerge: false, |
| 51 | 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 | 66 | Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), |
| 61 | 67 | ).toEqual([ |
| 62 | 68 | "continueThreadsAfterServerUpdate", |
| 63 | "defaultThreadEnvMode", | |
| 64 | 69 | "newWorktreesStartFromOrigin", |
| 65 | 70 | "sidebarAutoSettleAfterDays", |
| 66 | 71 | "sidebarAutoSettleOnMerge", |
| ... | ... | @@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => { |
| 206 | 211 | environmentId: boxId, |
| 207 | 212 | label: "Remote Box", |
| 208 | 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 | 24 | "continueThreadsAfterServerUpdate", |
| 25 | 25 | "sidebarAutoSettleAfterDays", |
| 26 | 26 | "sidebarAutoSettleOnMerge", |
| 27 | "defaultThreadEnvMode", | |
| 28 | 27 | "newWorktreesStartFromOrigin", |
| 29 | 28 | "sourceControlWritingStyle", |
| 30 | 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 | 2 | import * as Duration from "effect/Duration"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | 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 | 11 | import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; |
| 7 | 12 | import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; |
| 8 | 13 | import { |
| ... | ... | @@ -11,7 +16,7 @@ import { |
| 11 | 16 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT, |
| 12 | 17 | ProviderOptionSelections, |
| 13 | 18 | } from "./model.ts"; |
| 14 | import { ModelSelection } from "./orchestration.ts"; | |
| 19 | import { ModelSelection, ProjectScript } from "./orchestration.ts"; | |
| 15 | 20 | import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; |
| 16 | 21 | import { |
| 17 | 22 | DEFAULT_PREVIEW_APPEARANCE, |
| ... | ... | @@ -856,6 +861,22 @@ export const ServerSettings = Schema.Struct({ |
| 856 | 861 | * between a desktop window and a phone attached to the same server. |
| 857 | 862 | */ |
| 858 | 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 | 880 | sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( |
| 860 | 881 | Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), |
| 861 | 882 | ), |
| ... | ... | @@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1116 | 1137 | enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), |
| 1117 | 1138 | continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), |
| 1118 | 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 | 1152 | sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), |
| 1120 | 1153 | sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1121 | 1154 | backgroundActivity: Schema.optionalKey( |
packages/shared/src/projectScripts.ts+21-1| ... | ... | @@ -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 | 23 | interface ProjectScriptRuntimeEnvInput { |
| 4 | 24 | project: { |
packages/shared/src/serverSettings.test.ts+168| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | ProjectId, | |
| 3 | 4 | ProviderDriverKind, |
| 4 | 5 | ProviderInstanceId, |
| 5 | 6 | UsageLimitSourceId, |
| ... | ... | @@ -9,14 +10,181 @@ import * as Duration from "effect/Duration"; |
| 9 | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 11 | import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; |
| 11 | 12 | import { createModelSelection } from "./model.ts"; |
| 13 | import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; | |
| 12 | 14 | import { |
| 13 | 15 | applyServerSettingsPatch, |
| 14 | 16 | isModelSelectionProviderEnabled, |
| 15 | 17 | parsePersistedServerObservabilitySettings, |
| 16 | 18 | resolveSourceControlWriterModelSelection, |
| 19 | resolveProjectAgentBrowserAccess, | |
| 20 | resolveProjectAutoPull, | |
| 17 | 21 | } from "./serverSettings.ts"; |
| 18 | 22 | |
| 19 | 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 | 188 | it("ignores missing and blank persisted observability URLs", () => { |
| 21 | 189 | expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ |
| 22 | 190 | otlpTracesUrl: undefined, |
packages/shared/src/serverSettings.ts+54| ... | ... | @@ -3,6 +3,7 @@ import { |
| 3 | 3 | isProviderAvailable, |
| 4 | 4 | resolveProviderInstanceEnabled, |
| 5 | 5 | type ModelSelection, |
| 6 | type ProjectId, | |
| 6 | 7 | type ProviderDriverKind, |
| 7 | 8 | type ServerProvider, |
| 8 | 9 | ServerSettings, |
| ... | ... | @@ -23,6 +24,27 @@ import { |
| 23 | 24 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 24 | 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 | 48 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; |
| 27 | 49 | |
| 28 | 50 | const getLegacyProviderSettings = ( |
| ... | ... | @@ -151,6 +173,8 @@ export function applyServerSettingsPatch( |
| 151 | 173 | // Merged per entry below; its `null` removals must not reach deepMerge. |
| 152 | 174 | usageLimitSources: usageLimitSourcesPatch, |
| 153 | 175 | usagePriceOverrides: usagePriceOverridesPatch, |
| 176 | projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, | |
| 177 | projectAutoPullOverrides: projectAutoPullOverridesPatch, | |
| 154 | 178 | ...patchForMerge |
| 155 | 179 | } = patch; |
| 156 | 180 | const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); |
| ... | ... | @@ -207,6 +231,36 @@ export function applyServerSettingsPatch( |
| 207 | 231 | ...(patch.providerInstances !== undefined |
| 208 | 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 | 264 | ...(usageLimitSourcesPatch !== undefined |
| 211 | 265 | ? { |
| 212 | 266 | usageLimitSources: mergeSettingsEntries( |