From 6e8931d75615ec020e602e97df2694b0541a6272 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:20:54 +0300 Subject: [PATCH 01/26] perf(client): reduce remote request and message sync overhead (#11029) --- .../src/authorization/remote.ts | 26 +-- .../src/environment/descriptor.ts | 6 +- .../src/remotePerformance.bench.ts | 165 ++++++++++++++++++ packages/client-runtime/src/rpc/http.ts | 14 ++ .../src/state/environmentHttpAuth.test.ts | 11 ++ .../src/state/environmentHttpAuth.ts | 22 ++- .../src/state/pullRequestDiffHttp.ts | 3 +- packages/client-runtime/src/state/session.ts | 3 +- .../src/state/shellSnapshotHttp.ts | 3 +- .../src/state/threadReducer.test.ts | 48 +++++ .../client-runtime/src/state/threadReducer.ts | 39 ++--- .../src/state/threadSnapshotHttp.ts | 3 +- 12 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 packages/client-runtime/src/remotePerformance.bench.ts diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 538c0aa114a3..be863e189a9e 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { executeEnvironmentHttpRequest, - makeEnvironmentHttpApiClient, + makeEnvironmentHttpApiGroupClient, type RemoteEnvironmentRequestError, } from "../rpc/http.ts"; @@ -93,11 +93,11 @@ export const exchangeRemoteDpopAccessToken = Effect.fn( readonly dpopProof: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); const response = yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ + client.token({ headers: { dpop: input.dpopProof }, payload: { grant_type: AuthTokenExchangeGrantType, @@ -121,11 +121,11 @@ export const bootstrapRemoteBearerSession = Effect.fn( readonly clientMetadata?: AuthClientPresentationMetadata; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ + client.token({ headers: {}, payload: { grant_type: AuthTokenExchangeGrantType, @@ -146,11 +146,11 @@ export const fetchRemoteSessionState = Effect.fn( readonly bearerToken: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ + client.session({ headers: { authorization: `Bearer ${input.bearerToken}`, }, @@ -166,11 +166,11 @@ export const fetchRemoteDpopSessionState = Effect.fn( readonly dpopProof: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ + client.session({ headers: { authorization: `DPoP ${input.accessToken}`, dpop: input.dpopProof, @@ -186,11 +186,11 @@ export const issueRemoteWebSocketTicket = Effect.fn( readonly bearerToken: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ + client.webSocketTicket({ headers: { authorization: `Bearer ${input.bearerToken}`, }, @@ -206,11 +206,11 @@ export const issueRemoteDpopWebSocketTicket = Effect.fn( readonly dpopProof: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ + client.webSocketTicket({ headers: { authorization: `DPoP ${input.accessToken}`, dpop: input.dpopProof, diff --git a/packages/client-runtime/src/environment/descriptor.ts b/packages/client-runtime/src/environment/descriptor.ts index d49a0d9a8904..1f92b296c5e3 100644 --- a/packages/client-runtime/src/environment/descriptor.ts +++ b/packages/client-runtime/src/environment/descriptor.ts @@ -1,17 +1,17 @@ import * as Effect from "effect/Effect"; import { environmentEndpointUrl } from "./endpoint.ts"; -import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient } from "../rpc/http.ts"; const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; export const fetchRemoteEnvironmentDescriptor = Effect.fn( "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "metadata"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.metadata.descriptor(), + client.descriptor(), ); }); diff --git a/packages/client-runtime/src/remotePerformance.bench.ts b/packages/client-runtime/src/remotePerformance.bench.ts new file mode 100644 index 000000000000..85b4cf732c42 --- /dev/null +++ b/packages/client-runtime/src/remotePerformance.bench.ts @@ -0,0 +1,165 @@ +import { + EnvironmentId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { bench, describe } from "vite-plus/test"; + +import { issueRemoteWebSocketTicket } from "./authorization/remote.ts"; +import { PrimaryConnectionTarget } from "./connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "./environment/descriptor.ts"; +import type { RemoteEnvironmentRequestError } from "./rpc/http.ts"; +import { fetchEnvironmentThreadSnapshot } from "./state/threadSnapshotHttp.ts"; +import { applyThreadDetailEvent } from "./state/threadReducer.ts"; + +const timestamp = "2026-09-01T00:00:00.000Z"; +const thread: OrchestrationThread = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Remote thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + pullRequests: [], + messages: Array.from({ length: 100 }, (_, index) => ({ + id: MessageId.make(`message-${index}`), + role: "assistant", + text: "Message text. ".repeat(40), + turnId: null, + streaming: false, + createdAt: timestamp, + updatedAt: timestamp, + })), + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +}; +const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("remote-1"), + label: "Remote", + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test/ws", +}); +const responses = { + "/.well-known/t3/environment": { + environmentId: target.environmentId, + label: target.label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + "/api/auth/websocket-ticket": { ticket: "test-ticket", expiresAt: timestamp }, + "/api/orchestration/threads/thread-1": { snapshotSequence: 1, thread }, +}; +const httpClient = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname as keyof typeof responses; + return HttpClientResponse.fromWeb(request, Response.json(responses[path])); + }), +); +const requests: Record< + string, + Effect.Effect +> = { + "read remote connection descriptor": fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }), + "issue remote WebSocket ticket": issueRemoteWebSocketTicket({ + httpBaseUrl: target.httpBaseUrl, + bearerToken: "test-token", + }), + "load remote snapshot with 100 messages": fetchEnvironmentThreadSnapshot({ + prepared: { + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: target.httpBaseUrl, + socketUrl: target.wsBaseUrl, + httpAuthorization: null, + target, + }, + threadId: thread.id, + signer: Option.none(), + }), +}; + +describe("remote HTTP processing with an in-memory transport", () => { + for (const [name, request] of Object.entries(requests)) { + bench( + name, + async () => { + await Effect.runPromise( + request.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + ); + }, + { warmupTime: 1_000, time: 1_500 }, + ); + } +}); + +const delta: OrchestrationEvent = { + eventId: EventId.make("delta"), + sequence: 2, + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt: timestamp, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: thread.id, + messageId: MessageId.make("message-99"), + role: "assistant", + text: " next", + turnId: null, + streaming: true, + createdAt: timestamp, + updatedAt: timestamp, + }, +}; + +describe("remote message replay", () => { + for (const count of [100, 1_000]) { + const loaded = { + ...thread, + messages: Array.from({ length: count }, (_, index) => ({ + ...thread.messages[0]!, + id: MessageId.make(`message-${index}`), + })), + }; + const event = { + ...delta, + payload: { ...delta.payload, messageId: loaded.messages.at(-1)!.id }, + }; + bench( + `apply 200 text deltas to ${count} loaded messages`, + () => { + let current: OrchestrationThread = loaded; + for (let index = 0; index < 200; index += 1) { + const result = applyThreadDetailEvent(current, event); + if (result.kind === "updated") current = result.thread; + } + }, + { warmupTime: 1_000, time: 1_500 }, + ); + } +}); diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index e52e012954f7..d1c470d171ff 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -99,6 +99,20 @@ export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => baseUrl: remoteApiBaseUrl(httpBaseUrl), }); +export const makeEnvironmentHttpApiGroupClient = < + Group extends keyof typeof EnvironmentHttpApi.groups, +>( + httpBaseUrl: string, + group: Group, +) => + Effect.flatMap(HttpClient.HttpClient, (httpClient) => + HttpApiClient.group(EnvironmentHttpApi, { + httpClient, + group, + baseUrl: remoteApiBaseUrl(httpBaseUrl), + }), + ); + /** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */ export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) => HttpApiClient.urlBuilder(EnvironmentHttpApi, { diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 2d4152356ad5..f8fb0429cc5a 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -215,6 +215,17 @@ const LOADERS: ReadonlyArray<{ ]; describe("authenticated environment HTTP requests", () => { + it.effect.each(LOADERS)("rejects an invalid $name response", (loader) => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json({})); + const result = yield* loader + .load(harness.input) + .pipe(Effect.provide(harness.httpLayer), Effect.asVoid, Effect.flip); + expect(result._tag).toBe("RemoteEnvironmentAuthInvalidJsonError"); + expect(harness.calls).toHaveLength(1); + }), + ); + it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json(loader.response)); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index 019b52ddd359..29dd08f9f188 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -1,14 +1,14 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import { FetchHttpClient, type HttpClient, type HttpMethod } from "effect/unstable/http"; +import { FetchHttpClient, type HttpMethod } from "effect/unstable/http"; import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { executeEnvironmentHttpRequest, - makeEnvironmentHttpApiClient, + makeEnvironmentHttpApiGroupClient, RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError, type RemoteEnvironmentRequestError, @@ -86,20 +86,30 @@ const buildEnvironmentAuthHeaders = ( */ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest", -)(function* (input: { +)(function* < + Group extends Parameters[1], + A, + E, + R, +>(input: { readonly prepared: PreparedConnection; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly method: HttpMethod.HttpMethod; readonly url: (httpBaseUrl: string) => string; readonly timeoutMs: number; + readonly group: Group; readonly request: (input: { - readonly client: Effect.Success>; + readonly client: Effect.Success>>; readonly headers: EnvironmentHttpAuthHeaders; }) => Effect.Effect; /** Some endpoints report rejected credentials in a successful response. */ readonly isUnauthorizedResponse?: (response: NoInfer) => boolean; -}): Effect.fn.Return { +}): Effect.fn.Return< + A, + RemoteEnvironmentRequestError, + Effect.Services>> | R +> { let httpBaseUrl = input.prepared.httpBaseUrl; return yield* Effect.gen(function* () { let rejectedAccessToken: string | undefined; @@ -132,7 +142,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( } const requestUrl = input.url(httpBaseUrl); - const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group); const headers = yield* buildEnvironmentAuthHeaders( authorization, input.method, diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.ts index f1a250e61018..dd514259d983 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.ts @@ -50,10 +50,11 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "pullRequests", method: "POST", url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(), timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS, - request: ({ client, headers }) => client.pullRequests.diff({ payload: input.diff, headers }), + request: ({ client, headers }) => client.diff({ payload: input.diff, headers }), }).pipe( Effect.mapError((error) => error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 5ed60afc8a5e..9a0a9dfe9a5f 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -49,10 +49,11 @@ export const fetchEnvironmentSessionState = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "auth", method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"), timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, - request: ({ client, headers }) => client.auth.session({ headers }), + request: ({ client, headers }) => client.session({ headers }), // This endpoint returns 200 with authenticated:false for expired credentials. isUnauthorizedResponse: (response) => !response.authenticated, }); diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index aa1ad9081e05..84ab1a3f1d4b 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -32,10 +32,11 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "orchestration", method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, - request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }), + request: ({ client, headers }) => client.shellSnapshot({ headers }), }); }); diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 5b70ca9ef0aa..54e1eb8d1f65 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -526,6 +526,54 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.message-sent", () => { + it.each([ + ["first", ["first+", "middle", "last"]], + ["middle", ["first", "middle+", "last"]], + ["last", ["first", "middle", "last+"]], + ["new", ["first", "middle", "last", "+"]], + ] as const)("applies a delta to %s without changing other messages", (id, texts) => { + const messages = Object.freeze( + ["first", "middle", "last"].map((name) => + Object.freeze({ + id: MessageId.make(name), + role: "assistant" as const, + text: name, + turnId: null, + streaming: false, + createdAt: baseThread.createdAt, + updatedAt: baseThread.updatedAt, + }), + ), + ); + const result = applyThreadDetailEvent( + { ...baseThread, messages }, + { + ...baseEventFields, + sequence: 6, + occurredAt: baseThread.updatedAt, + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId: MessageId.make(id), + role: "assistant", + text: "+", + turnId: null, + streaming: true, + createdAt: baseThread.createdAt, + updatedAt: baseThread.updatedAt, + }, + }, + ); + expect(result.kind).toBe("updated"); + if (result.kind !== "updated") return; + expect(result.thread.messages.map((message) => message.text)).toEqual(texts); + for (const [index, message] of messages.entries()) { + if (message.id !== id) expect(result.thread.messages[index]).toBe(message); + } + }); + it("appends a new message", () => { const result = applyThreadDetailEvent(baseThread, { ...baseEventFields, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index ce9aa94b1f06..c55c1fe64dd8 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -386,27 +386,24 @@ export function applyThreadDetailEvent( updatedAt: event.payload.updatedAt, }; - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? Arr.map(thread.messages, (entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming ? {} : { updatedAt: message.updatedAt }), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : Arr.append(thread.messages, message); + let found = false; + const messages = thread.messages.map((entry) => { + if (entry.id !== message.id) return entry; + found = true; + return { + ...entry, + text: message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), + ...(message.streaming ? {} : { updatedAt: message.updatedAt }), + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + }; + }); + if (!found) messages.push(message); // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index a82d4c1555c1..6af74067faed 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -45,12 +45,13 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "orchestration", method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`), timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => - client.orchestration.threadSnapshot({ + client.threadSnapshot({ params: { threadId: input.threadId }, payload: { ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), From e1c94f703f05b43f2935d09ed1ef882d0ee13ff8 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:21:27 -0700 Subject: [PATCH 02/26] fix(web): refresh usage limit countdowns without switching tabs (#11187) Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> --- apps/web/src/components/usage/UsageLimits.tsx | 8 +- .../usage/UsagePage.refresh.test.tsx | 179 ++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 5 +- 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/usage/UsagePage.refresh.test.tsx diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index e50547b66008..6d30d49a7e4a 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -316,17 +316,17 @@ export function ResetCredits({ /** * Subscription quota across every connected environment's providers and hubs, - * pooled per provider. Countdowns anchor to render time rather than ticking: a - * live clock would repaint the page every minute for no decision-changing gain. + * pooled per provider. The page advances `now` on explicit refresh rather than + * ticking: a live clock would repaint the page for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, + now, }: { readonly selectedEnvironmentIds: ReadonlySet | null; + readonly now: number; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); - // Anchored once per mount on purpose: countdowns must not tick (see above). - const [now] = useState(() => Date.now()); const selected = selectedEnvironmentIds === null ? presentations diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx new file mode 100644 index 000000000000..d6d300ce35b0 --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -0,0 +1,179 @@ +import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + presentations: new Map(), + refreshProviders: vi.fn(async () => undefined), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); +vi.mock("../../env", () => ({ isElectron: false })); +vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); +vi.mock("../../state/usage", () => ({ + useUsage: () => ({ + merged: mergeUsage([], USAGE_CONTRACT_VERSION), + environments: [ + { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + error: null, + summary: null, + }, + ], + selectedEnvironments: [ + { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + error: null, + summary: null, + }, + ], + isPending: false, + isPartial: false, + refresh: async () => undefined, + }), +})); +vi.mock("./usagePagePreferences", () => ({ + readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), + saveUsagePagePreferences: vi.fn(), +})); +vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); +vi.mock("../ui/select", () => ({ + Select: "select", + SelectItem: "option", + SelectPopup: "div", + SelectTrigger: "div", + SelectValue: "span", +})); +vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); +vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" })); +vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" })); +vi.mock("../ui/menu", () => ({ + Menu: "div", + MenuCheckboxItem: "div", + MenuItem: "div", + MenuPopup: "div", + MenuSeparator: "hr", + MenuTrigger: "div", +})); +vi.mock("../WorkspaceBreadcrumb", () => ({ + WorkspaceBreadcrumb: "div", + WorkspaceBreadcrumbItem: "div", + WorkspaceBreadcrumbSeparator: "span", +})); +vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); +vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); +vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); +vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); +vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); +vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) })); + +import { UsagePage } from "./UsagePage"; + +let renderer: ReactTestRenderer; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + state.refreshProviders.mockClear(); + state.presentations = new Map([ + [ + EnvironmentId.make("test"), + { + entry: { target: { label: "Test" } }, + connection: { phase: "connected" }, + serverConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-11T12:00:00Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-09-11T12:00:00Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + windowDurationMins: 300, + resetsAt: "2026-09-11T14:00:00Z", + }, + ], + }, + }, + ], + }, + }, + ], + ]); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it.each([0, 1])( + "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged", + async (buttonIndex) => { + await act(() => { + renderer = create(); + }); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 2h 0m"); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z")); + await act(async () => { + renderer.root + .findAllByProps({ "aria-label": "Refresh limits" }) + .filter((node) => node.type === "button") + .at(buttonIndex)! + .props.onClick(); + }); + expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} }); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 1h 30m"); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).not.toContain("in 2h 0m"); + }, +); + +it("uses the current time when returning to limits from tokens", async () => { + await act(() => { + renderer = create(); + }); + const selectMetric = (metric: string) => { + renderer.root + .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! + .props.onValueChange([metric]); + }; + await act(() => selectMetric("tokens")); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z")); + await act(() => selectMetric("limits")); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 1h 0m"); + expect(state.refreshProviders).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 21970c675596..2da7414d9337 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -104,6 +104,7 @@ export function UsagePage() { const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); + const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = @@ -159,6 +160,7 @@ export function UsagePage() { }); }; const selectMetric = (nextMetric: UsageMetric) => { + if (nextMetric === "limits") setLimitsNow(Date.now()); const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); @@ -177,6 +179,7 @@ export function UsagePage() { } }), ).finally(() => { + setLimitsNow(Date.now()); refreshingRef.current = false; setIsRefreshing(false); }); @@ -350,7 +353,7 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( From fb52d125b78b2ed1638a719a7546fad9c695c620 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 13:53:45 -0700 Subject: [PATCH 03/26] fix(client-runtime): typecheck device hub ticket request on main (#11304) Co-authored-by: Claude Fable 5 --- packages/client-runtime/src/state/deviceHubAccess.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/deviceHubAccess.ts b/packages/client-runtime/src/state/deviceHubAccess.ts index 328dea83ce7a..4c87aa538ac1 100644 --- a/packages/client-runtime/src/state/deviceHubAccess.ts +++ b/packages/client-runtime/src/state/deviceHubAccess.ts @@ -50,10 +50,11 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi prepared: input.prepared, signer, remoteAuthorization, + group: "auth", method: "POST", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"), timeoutMs: TICKET_TIMEOUT_MS, - request: ({ client, headers }) => client.auth.webSocketTicket({ headers }), + request: ({ client, headers }) => client.webSocketTicket({ headers }), }); return { httpBase, From 2c0e891740f86205784506a974096cf110b64da7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 13:56:39 -0700 Subject: [PATCH 04/26] feat(settings): add per-project overrides for scopable server settings (#11176) --- .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/git/GitManager.ts | 31 ++- .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 4 + .../Layers/ProviderCommandReactor.ts | 24 +- .../Layers/ProviderRuntimeIngestion.ts | 11 +- .../Services/ProjectionSnapshotQuery.ts | 2 +- .../ThreadSettlementReactor.test.ts | 82 +++++++ .../orchestration/ThreadSettlementReactor.ts | 61 ++++- .../provider/Layers/ProviderService.test.ts | 47 +++- .../src/provider/Layers/ProviderService.ts | 96 +++++--- apps/server/src/serverRuntimeStartup.test.ts | 70 ++++-- apps/server/src/serverRuntimeStartup.ts | 26 ++- apps/server/src/serverSettings.test.ts | 112 ++++++++++ apps/server/src/serverSettings.ts | 178 ++++++++++++--- apps/server/src/vcs/VcsStatusBroadcaster.ts | 4 +- .../src/state/sharedSettings.test.ts | 12 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/settings.ts | 84 +++++++ packages/shared/package.json | 4 + packages/shared/src/projectScripts.ts | 38 +++- packages/shared/src/projectSettings.test.ts | 208 ++++++++++++++++++ packages/shared/src/projectSettings.ts | 124 +++++++++++ packages/shared/src/serverSettings.test.ts | 18 +- packages/shared/src/serverSettings.ts | 143 +++++++++--- 25 files changed, 1229 insertions(+), 154 deletions(-) create mode 100644 packages/shared/src/projectSettings.test.ts create mode 100644 packages/shared/src/projectSettings.ts diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2aab17b27a76..7bf081abc91d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, + projectSettingsOverrides: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f60eb2781872..1a14fb5be5bc 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -29,9 +29,16 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + type ProjectId, SourceControlProviderError, type SourceControlWritingStyleSettings, + type ThreadId, } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, @@ -661,6 +668,28 @@ export const make = Effect.gen(function* () { const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettings.ServerSettingsService; + // Optional: git actions also run from the CLI and tests without orchestration. + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); + /** Environment settings with the acting project's overrides applied. */ + const projectSettingsFor = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly threadId?: ThreadId | undefined; + }) { + const settings = yield* serverSettingsService.getSettings; + if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings; + const projectId = yield* ( + input.threadId !== undefined + ? projectionQuery.value + .getThreadShellById(input.threadId) + .pipe(Effect.map(Option.map((thread) => thread.projectId))) + : projectionQuery.value + .getActiveProjectByWorkspaceRoot(input.cwd) + .pipe(Effect.map(Option.map((project) => project.id))) + ).pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings; + }); const readRepositoryInstructions = (cwd: string, fileName: string) => Effect.gen(function* () { const root = yield* fileSystem.realPath(cwd); @@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () { let commitMessageForStep = input.commitMessage; let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - const textGenerationSettings = yield* serverSettingsService.getSettings.pipe( + const textGenerationSettings = yield* projectSettingsFor(input).pipe( Effect.flatMap((settings) => settings.sourceControlWriterModelSelection === null ? Effect.succeed({ diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 5849123c55d6..be66f3cf4b43 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (context._tag === "Some") { assert.deepEqual(context.value, { id: ThreadId.make("thread-1"), + projectId: asProjectId("project-1"), title: "Thread 1", session: snapshot.threads[0]?.session, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 066c60760ca5..efb7bba8f15b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({ const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({ id: ThreadId, + projectId: ProjectId, title: Schema.String, session: Schema.NullOr(ProjectionThreadSessionDbRowSchema), }); @@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql` SELECT threads.thread_id AS id, + threads.project_id AS "projectId", threads.title, sessions.thread_id AS "threadId", sessions.status, @@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.threadId === null ? null : row, })), @@ -3164,6 +3167,7 @@ pending_approval_requests AS ( ); return Option.map(context, (row) => ({ id: row.id, + projectId: row.projectId, title: row.title, session: row.session === null ? null : mapSessionRow(row.session), })); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9b125922137c..c5d120106a19 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -54,6 +54,7 @@ import { resolveSourceControlWriterModelSelection, ServerSettingsService, } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -329,6 +330,16 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + /** Environment settings with the thread's project overrides applied. */ + const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const settings = yield* serverSettingsService.getSettings; + if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings; + const thread = yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null) + .settings; + }); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -996,7 +1007,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const settings = yield* serverSettingsService.getSettings; + const settings = yield* projectSettingsForThread(input.threadId); const modelSelection = settings.sourceControlWriterModelSelection === null ? settings.textGenerationModelSelection @@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () { }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread( + input.threadId, + ); const generated = yield* textGeneration .generateThreadTitle({ @@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }) ?? process.cwd(); - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = resolveProjectSettings( + yield* serverSettingsService.getSettings, + thread.projectId, + ).settings; const generated = yield* textGeneration.generateThreadTitle({ cwd, message, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d34fee4f981..964f60d3a306 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -51,6 +51,7 @@ import { import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { canReplaceThreadTitle } from "../threadTitles.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; @@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () { const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); @@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () { }); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, - (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), + (settings) => + resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming + ? "streaming" + : "buffered", ); const flushedMessageIds = assistantDeliveryMode === "buffered" diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 35d5bacc239c..fda0ac04556e 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadRuntimeContext: ( threadId: ThreadId, ) => Effect.Effect< - Option.Option>, + Option.Option>, ProjectionRepositoryError >; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 0690eea2d50e..c443ec75e0d5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -302,6 +302,35 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it("distinguishes a project that inherits the threshold from one that disables it", () => { + const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } }, + }); + const never = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null }, + }, + }); + assert.notStrictEqual(inherits, never); + }); + + it("ignores project overrides that do not touch settlement", () => { + const base = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, + }); + const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, + [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, + }, + }); + assert.strictEqual(base, unrelated); + }); + it.effect( "settles all-terminal links from snapshots and keeps open or unsynced links active", () => @@ -486,6 +515,59 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("a project override settles only that project's inactive threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const overriddenProject = ProjectId.make("overridden-project"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("inherits-thread"), + makeThread("overridden-thread", { projectId: overriddenProject }), + ], + [makeProject(), makeProject(overriddenProject, "/workspace/overridden")], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + projectSettingsOverrides: { + [overriddenProject]: { sidebarAutoSettleAfterDays: 1 }, + }, + }, + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Queue.take(fixture.settingsReads); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("overridden-thread")], + ); + + // Clearing the override is a settlement change, so the sweep re-arms. + yield* fixture.updateSettings({ + projectSettingsOverrides: { [overriddenProject]: null }, + sidebarAutoSettleAfterDays: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + // The static snapshot never records the first settlement, so the + // second sweep dispatches for both; the inheriting thread is new. + assert.include( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + ThreadId.make("inherits-thread"), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("starts without clients and skips protected threads before pull request lookup", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 61fc5d4ab863..b9041d2976ca 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,4 +1,5 @@ -import { CommandId } from "@t3tools/contracts"; +import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -32,6 +33,45 @@ export class ThreadSettlementReactor extends Context.Service< } >()("t3/orchestration/ThreadSettlementReactor") {} +/** @public Service construction is part of the canonical Effect module API. */ +/** Whether any environment default or project override can settle a thread. */ +function autoSettlementConfigured(settings: ServerSettingsValue): boolean { + if (settings.sidebarAutoSettleOnMerge || settings.sidebarAutoSettleAfterDays !== null) { + return true; + } + return Object.values(settings.projectSettingsOverrides).some( + (entry) => + entry.sidebarAutoSettleOnMerge === true || + (entry.sidebarAutoSettleAfterDays !== undefined && entry.sidebarAutoSettleAfterDays !== null), + ); +} + +/** Identity of every settlement input, so unrelated settings edits do not trigger a sweep. */ +/** @internal Exported for tests. */ +export function autoSettlementSettingsKey(settings: ServerSettingsValue): string { + return JSON.stringify([ + settings.sidebarAutoSettleOnMerge, + settings.sidebarAutoSettleAfterDays, + // Only entries that touch settlement, in a stable order, so a project + // override on an unrelated key does not queue a sweep. JSON drops + // undefined, so inherit (absent) and never (null) need distinct marks. + Object.entries(settings.projectSettingsOverrides) + .filter( + ([, entry]) => + entry.sidebarAutoSettleOnMerge !== undefined || + entry.sidebarAutoSettleAfterDays !== undefined, + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([projectId, entry]) => [ + projectId, + entry.sidebarAutoSettleOnMerge ?? "inherit", + entry.sidebarAutoSettleAfterDays === undefined + ? "inherit" + : entry.sidebarAutoSettleAfterDays, + ]), + ]); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -46,7 +86,7 @@ export const make = Effect.gen(function* () { mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, ) { const settings = yield* settingsService.getSettings; - if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) { + if (!autoSettlementConfigured(settings)) { return; } const snapshot = yield* snapshots.getShellSnapshot(); @@ -60,7 +100,10 @@ export const make = Effect.gen(function* () { // dispatch skips it for this snapshot instead of retrying through a lookup. const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { - const settings = yield* settingsService.getSettings; + const settings = resolveProjectSettings( + yield* settingsService.getSettings, + thread.projectId, + ).settings; const decisionNow = DateTime.formatIso(yield* DateTime.now); const settledAt = resolveAutoSettlementAt({ thread, @@ -254,8 +297,7 @@ export const make = Effect.gen(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); - let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; - let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; + let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( Effect.gen(function* () { yield* worker.enqueue(undefined); @@ -264,14 +306,11 @@ export const make = Effect.gen(function* () { ); yield* forkParked( Stream.runForEach(settingsChanges, (settings) => { - if ( - settings.sidebarAutoSettleAfterDays === lastAfterDays && - settings.sidebarAutoSettleOnMerge === lastOnMerge - ) { + const key = autoSettlementSettingsKey(settings); + if (key === lastSettlementSettings) { return Effect.void; } - lastAfterDays = settings.sidebarAutoSettleAfterDays; - lastOnMerge = settings.sidebarAutoSettleOnMerge; + lastSettlementSettings = key; return worker.enqueue(undefined); }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 9a3fcb8d65f4..17f49355a904 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4802,7 +4802,8 @@ describe("agent browser access", () => { const startSessionWith = ( access: boolean | { readonly browser: boolean; readonly device: boolean }, threadId: ThreadId, - projectOverride?: boolean, + projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean }, + options?: { readonly withoutOrchestration?: boolean }, ) => Effect.gen(function* () { const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser; @@ -4875,13 +4876,26 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(projectionLayer), + Layer.provide(options?.withoutOrchestration ? Layer.empty : projectionLayer), Layer.provide( ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, enableAgentDeviceAccess, - projectAgentBrowserAccessOverrides: - projectOverride === undefined ? {} : { [projectId]: projectOverride }, + projectSettingsOverrides: + projectOverride === undefined + ? {} + : typeof projectOverride === "boolean" + ? { [projectId]: { enableAgentBrowserAccess: projectOverride } } + : { + [projectId]: { + ...(projectOverride.browser !== undefined + ? { enableAgentBrowserAccess: projectOverride.browser } + : {}), + ...(projectOverride.device !== undefined + ? { enableAgentDeviceAccess: projectOverride.device } + : {}), + }, + }, }), ), Layer.provide(serverConfigTestLayer), @@ -4965,4 +4979,29 @@ describe("agent browser access", () => { assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("a project device override grants device access when the environment denies it", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-device-on"); + const issued = yield* startSessionWith({ browser: false, device: false }, threadId, { + device: true, + }); + assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + // Without orchestration the project cannot be resolved, so an overridden + // capability is withheld; one no project overrides keeps its environment value. + it.effect("withholds only the overridden capability when the project cannot be resolved", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-no-orchestration-device-override"); + const issued = yield* startSessionWith( + { browser: true, device: true }, + threadId, + { device: false }, + { withoutOrchestration: true }, + ); + assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d04dcae7f126..7ffbb113d549 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -28,16 +28,18 @@ import { ProviderUploadFeedbackInput, ThreadId, TurnId, + type ProjectId, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, type ProviderSession, + type ServerSettings as ServerSettingsValue, } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -864,34 +866,40 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + const agentAccessSettings = Effect.fn("ProviderService.agentAccessSettings")( function* (threadId: ThreadId) { const settings = yield* serverSettings.getSettings; - if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { - return settings.enableAgentBrowserAccess; - } + const entries = Object.values(settings.projectSettingsOverrides); + const browserOverridden = entries.some( + (entry) => entry.enableAgentBrowserAccess !== undefined, + ); + const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined); + const environment = { + browser: settings.enableAgentBrowserAccess, + device: settings.enableAgentDeviceAccess, + }; + if (!browserOverridden && !deviceOverridden) return environment; // Provider-only runtimes may omit orchestration. An unresolved project - // must not bypass an explicit browser override. - if (Option.isNone(projectionQuery)) return false; + // must not bypass an explicit project override, but a capability no + // project overrides keeps its environment value. + const denied = { + browser: browserOverridden ? false : environment.browser, + device: deviceOverridden ? false : environment.device, + }; + if (Option.isNone(projectionQuery)) return denied; const thread = yield* projectionQuery.value.getThreadShellById(threadId); - if (Option.isNone(thread)) return false; - return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + if (Option.isNone(thread)) return denied; + const resolved = resolveProjectSettings(settings, thread.value.projectId).settings; + return { + browser: resolved.enableAgentBrowserAccess, + device: resolved.enableAgentDeviceAccess, + }; }, Effect.catch((cause) => Effect.logWarning( - "Could not read server settings; withholding agent browser access for this session.", + "Could not read server settings; withholding agent browser and device access for this session.", { cause }, - ).pipe(Effect.as(false)), - ), - ); - - const agentDeviceAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentDeviceAccess), - Effect.catch((cause) => - Effect.logWarning( - "Could not read server settings; withholding agent device access for this session.", - { cause }, - ).pipe(Effect.as(false)), + ).pipe(Effect.as({ browser: false, device: false })), ), ); @@ -899,8 +907,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, ) { const capabilities = new Set(["pull-requests"]); - if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview"); - if (yield* agentDeviceAccessEnabled) capabilities.add("device"); + const access = yield* agentAccessSettings(threadId); + if (access.browser) capabilities.add("preview"); + if (access.device) capabilities.add("device"); return capabilities; }); @@ -2224,10 +2233,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const runStopAll = Effect.fn("runStopAll")(function* () { - const continueAfterRestart = yield* serverSettings.getSettings.pipe( - Effect.map((settings) => settings.continueThreadsAfterServerUpdate), - Effect.orElseSucceed(() => false), + // Continuation is project-scopable, so decide it per session's project; + // without orchestration the environment value is all there is. + const stopSettings = yield* serverSettings.getSettings.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), ); + const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* ( + threadId: ThreadId, + ) { + if (Option.isNone(stopSettings)) return false; + const settings = stopSettings.value; + const overridden = Object.values(settings.projectSettingsOverrides).some( + (entry) => entry.continueThreadsAfterServerUpdate !== undefined, + ); + if (!overridden || Option.isNone(projectionQuery)) { + return settings.continueThreadsAfterServerUpdate; + } + const thread = yield* projectionQuery.value + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none<{ projectId: ProjectId }>())); + if (Option.isNone(thread)) return settings.continueThreadsAfterServerUpdate; + return resolveProjectSettings(settings, thread.value.projectId).settings + .continueThreadsAfterServerUpdate; + }); const properties = yield* Ref.modify(turnAnalytics, (state) => { const completed: Array>> = []; for (const [sessionKey, session] of state.sessions) { @@ -2253,15 +2282,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ).pipe(Effect.map((sessionsByAdapter) => sessionsByAdapter.flatMap((sessions) => sessions))); yield* Effect.forEach(activeSessions, (session) => - Effect.flatMap(nowIso, (lastRuntimeEventAt) => - upsertSessionBinding(session, session.threadId, { - ...(continueAfterRestart && session.status === "running" && session.activeTurnId + Effect.gen(function* () { + const continueAfterRestart = + session.status === "running" && session.activeTurnId + ? yield* continueAfterRestartFor(session.threadId) + : false; + const lastRuntimeEventAt = yield* nowIso; + yield* upsertSessionBinding(session, session.threadId, { + ...(continueAfterRestart && session.activeTurnId ? { continueAfterServerUpdate: session.activeTurnId } : {}), lastRuntimeEvent: "provider.stopAll", lastRuntimeEventAt, - }), - ), + }); + }), ).pipe(Effect.asVoid); yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 0426df44bcea..37dbd7394a00 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,5 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; @@ -40,27 +46,43 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }; }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; - const project = (workspaceRoot: string, autoPull = true) => - ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; - - yield* ServerRuntimeStartup.autoPullProjects([ - project("/clean"), - project("/current"), - project("/dirty"), - project("/ahead"), - project("/feature"), - project("/disabled", false), - ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + const project = (workspaceRoot: string) => + ({ id: ProjectId.make(workspaceRoot), workspaceRoot }) as never; + const overrides = (entries: Record) => ({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: Object.fromEntries( + Object.entries(entries).map(([root, defaultAutoPull]) => [ + ProjectId.make(root), + { defaultAutoPull }, + ]), + ), + }); + + yield* ServerRuntimeStartup.autoPullProjects( + [ + project("/clean"), + project("/current"), + project("/dirty"), + project("/ahead"), + project("/feature"), + project("/disabled"), + ], + overrides({ + "/clean": true, + "/current": true, + "/dirty": true, + "/ahead": true, + "/feature": true, + "/disabled": false, + }), + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); pulled.length = 0; yield* ServerRuntimeStartup.autoPullProjects( - [project("/inherited", false), project("/opted-out"), project("/dirty", false)], - { - defaultAutoPull: true, - projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, - }, + [project("/inherited"), project("/opted-out"), project("/dirty")], + { ...overrides({ "/opted-out": false }), defaultAutoPull: true }, ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/inherited"]); }), @@ -223,7 +245,17 @@ it.effect.each([ }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), + Effect.provide( + ServerSettings.layerTest({ + defaultModelSelection: machineSelection, + projectSettingsOverrides: + existing && projectSelection + ? { + [ProjectId.make("existing-project")]: { defaultModelSelection: projectSelection }, + } + : {}, + }), + ), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -244,7 +276,7 @@ it.effect.each([ id: ProjectId.make("existing-project"), title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: projectSelection, + defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 3d04abaa1914..90d6c3c576a0 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, + type ServerSettings as ServerSettingsValue, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -10,7 +11,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -229,7 +230,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? defaultModelSelection; + resolveProjectSettings(settings, nextProjectId, existingProject.value).settings + .defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { @@ -479,14 +481,19 @@ export const reconcileProviderSessions = Effect.gen(function* () { const providerService = yield* ProviderService.ProviderService; const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settings = yield* ServerSettings.ServerSettingsService; - const continueAfterRestart = yield* settings.getSettings.pipe( - Effect.map((value) => value.continueThreadsAfterServerUpdate), + const restartSettings = yield* settings.getSettings.pipe( + Effect.map(Option.some), Effect.catch((cause) => Effect.logWarning("could not read restart continuation preference", { cause }).pipe( - Effect.as(false), + Effect.as(Option.none()), ), ), ); + const continueAfterRestartFor = (projectId: ProjectId) => + Option.isSome(restartSettings) + ? resolveProjectSettings(restartSettings.value, projectId).settings + .continueThreadsAfterServerUpdate + : false; const liveThreadIds = new Set( (yield* providerService.listSessions()).map((session) => session.threadId), @@ -568,7 +575,7 @@ export const reconcileProviderSessions = Effect.gen(function* () { // Runtime events advance the projection's turn, but not the directory's // last admitted turn. Use the projection to identify interrupted work. const interruptedByRestart = - continueAfterRestart && + continueAfterRestartFor(thread.projectId) && session.status === "running" && session.activeTurnId !== null && Option.isSome(binding) && @@ -742,16 +749,13 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, - settings: Pick< - typeof DEFAULT_SERVER_SETTINGS, - "defaultAutoPull" | "projectAutoPullOverrides" - > = DEFAULT_SERVER_SETTINGS, + settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) + .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull) .map((project) => project.workspaceRoot), ), ]; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 5d2571e72dc3..208d75fb6517 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1,6 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS, + ModelSelection, + ProjectId, + ProjectScript, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -1279,4 +1282,113 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.include(persisted, '"valueRedacted": true'); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("folds legacy project overrides into projectSettingsOverrides once", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const legacyProject = ProjectId.make("project-legacy"); + const scriptedProject = ProjectId.make("project-scripted"); + const script: ProjectScript = { + id: "check", + name: "Check", + command: "npm test", + icon: "play", + runOnWorktreeCreate: false, + }; + const model = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + const modelJson = yield* Schema.encodeEffect(Schema.fromJsonString(ModelSelection))(model); + const scriptsJson = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(ProjectScript)), + )([script]); + for (const [projectId, modelColumn, envMode, autoPull, scripts] of [ + // The legacy project also carries aggregate scripts, but its stored + // null override reset them; the fold must not bring them back. + [legacyProject, modelJson, "worktree", 1, scriptsJson], + [scriptedProject, null, null, 0, scriptsJson], + ] as const) { + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, default_model_selection_json, + default_thread_env_mode, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${projectId}, ${"Project"}, ${`/tmp/${projectId}`}, ${modelColumn}, + ${envMode}, ${autoPull}, ${scripts}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + } + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + `{"projectAgentBrowserAccessOverrides":{"${legacyProject}":false},"projectAutoPullOverrides":{"${scriptedProject}":true},"projectScriptOverrides":{"${legacyProject}":null}}`, + ); + + const settings = yield* serverSettings.getSettings; + assert.isTrue(settings.projectSettingsFolded); + assert.deepEqual( + settings.projectSettingsOverrides, + { + [legacyProject]: { + enableAgentBrowserAccess: false, + defaultModelSelection: model, + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + }, + [scriptedProject]: { defaultAutoPull: true, defaultProjectScripts: [script] }, + }, + ); + // Derived legacy views keep older clients reading the same values. + assert.deepEqual( + settings.projectAutoPullOverrides, + { + [legacyProject]: true, + [scriptedProject]: true, + }, + ); + assert.deepEqual(settings.projectScriptOverrides, { + [scriptedProject]: [script], + }); + + // A reset survives the next load: the fold does not run again. + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { [legacyProject]: null }, + }); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + const persisted = yield* decodeServerSettings( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.parse(raw), + ); + assert.isTrue(persisted.projectSettingsFolded); + assert.isUndefined(persisted.projectSettingsOverrides[legacyProject]); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + + it.effect("leaves an unreadable settings.json untouched instead of folding over it", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, auto_pull, scripts_json, created_at, updated_at + ) + VALUES ( + ${"project-broken"}, ${"Project"}, ${"/tmp/project-broken"}, ${1}, ${"[]"}, + ${"2026-08-25T00:00:00.000Z"}, ${"2026-08-25T00:00:00.000Z"} + ) + `; + const broken = '{"defaultAutoPull": tru'; + yield* fileSystem.writeFileString(serverConfig.settingsPath, broken); + + const settings = yield* serverSettings.getSettings; + assert.isFalse(settings.projectSettingsFolded); + assert.deepEqual(settings.projectSettingsOverrides, {}); + // The user's file is still there to repair; nothing was written over it. + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), broken); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 0b64d445adf8..50f8649eaacb 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -15,7 +15,9 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - type ModelSelection, + ModelSelection, + ProjectScript, + type ProjectSettingsOverrides, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type UsageLimitSourceConfig, @@ -51,6 +53,7 @@ import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { applyServerSettingsPatch, + deriveLegacyProjectOverrides, isModelSelectionProviderEnabled, } from "@t3tools/shared/serverSettings"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -118,6 +121,7 @@ const normalizeServerSettings = ( encodeServerSettings(settings).pipe( Effect.flatMap(decodeServerSettings), Effect.map(foldProviderInstanceEnabledFlags), + Effect.map((next) => ({ ...next, ...deriveLegacyProjectOverrides(next) })), Effect.mapError( (cause) => new ServerSettingsError({ @@ -353,6 +357,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "providerHealthRefreshInterval", "sourceControlWriterModelSelection", "textGenerationModelSelection", + "pullRequestMergeMethod", ]); // Preserve both enabled states because provider history cannot recover a new opt-in. @@ -400,6 +405,93 @@ function stripDefaultServerSettings(current: unknown, defaults: unknown): unknow return Object.is(current, defaults) ? undefined : current; } +const decodeProjectScriptsJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(ProjectScript)), +); +const decodeModelSelectionJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.NullOr(ModelSelection)), +); + +interface LegacyProjectSettingsRow { + readonly projectId: string; + readonly defaultModelSelection: string | null; + readonly defaultThreadEnvMode: string | null; + readonly autoPull: number; + readonly scripts: string; +} + +/** + * One-time fold of the legacy per-project fields into `projectSettingsOverrides`: + * the three `project*Overrides` maps and the settings columns on the project + * aggregate. Keys already present in the generic record win. Marked with + * `projectSettingsFolded` so a later reset in the UI survives restarts. + */ +function foldLegacyProjectSettings( + settings: ServerSettings, + rows: ReadonlyArray, +): ServerSettings { + if (settings.projectSettingsFolded) return settings; + // Nothing to fold yet (fresh install): leave the marker off so the file + // stays sparse, and check again on the next load. + if ( + rows.length === 0 && + Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0 && + Object.keys(settings.projectAutoPullOverrides).length === 0 && + Object.keys(settings.projectScriptOverrides).length === 0 + ) { + return settings; + } + const entries: Record = { + ...settings.projectSettingsOverrides, + }; + const set = ( + projectId: string, + key: K, + value: ProjectSettingsOverrides[K] | undefined, + ) => { + if (value === undefined) return; + const entry = entries[projectId] ?? {}; + if (Object.hasOwn(entry, key)) return; + entries[projectId] = { ...entry, [key]: value }; + }; + for (const [projectId, value] of Object.entries(settings.projectAgentBrowserAccessOverrides)) { + set(projectId, "enableAgentBrowserAccess", value); + } + for (const [projectId, value] of Object.entries(settings.projectAutoPullOverrides)) { + set(projectId, "defaultAutoPull", value); + } + // A stored null meant "reset to machine defaults", which is now plain + // inheritance; the project's own aggregate scripts must not resurface. + const resetScripts = new Set(); + for (const [projectId, value] of Object.entries(settings.projectScriptOverrides)) { + if (value === null) resetScripts.add(projectId); + else set(projectId, "defaultProjectScripts", value); + } + for (const row of rows) { + const model = decodeModelSelectionJson(row.defaultModelSelection ?? "null"); + if (Option.isSome(model) && model.value !== null) { + set(row.projectId, "defaultModelSelection", model.value); + } + if (row.defaultThreadEnvMode === "local" || row.defaultThreadEnvMode === "worktree") { + set(row.projectId, "defaultThreadEnvMode", row.defaultThreadEnvMode); + } + if (row.autoPull === 1) set(row.projectId, "defaultAutoPull", true); + const scripts = decodeProjectScriptsJson(row.scripts); + if (Option.isSome(scripts) && scripts.value.length > 0 && !resetScripts.has(row.projectId)) { + set(row.projectId, "defaultProjectScripts", scripts.value); + } + } + const projectSettingsOverrides = Object.fromEntries( + Object.entries(entries).filter(([, entry]) => Object.keys(entry).length > 0), + ); + return { + ...settings, + projectSettingsOverrides, + projectSettingsFolded: true, + ...deriveLegacyProjectOverrides({ projectSettingsOverrides }), + }; +} + const make = Effect.gen(function* () { const { settingsPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; @@ -439,9 +531,36 @@ const make = Effect.gen(function* () { ), ); + const writeSettingsAtomically = Effect.fnUntraced( + function* (settings: ServerSettings) { + const sparseSettingsJson = yield* encodeServerSettingsJson( + stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, + ); + + return yield* writeFileStringAtomically({ + filePath: settingsPath, + contents: `${sparseSettingsJson}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), + ); + }, + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-file", + cause, + }), + ), + ); + const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + // A file that failed to decode must stay on disk for the user to repair; + // the fold below only writes when it started from the file's real contents. + let settingsFileTrusted = true; if (yield* readConfigExists) { const raw = yield* readRawConfig; @@ -452,6 +571,7 @@ const make = Effect.gen(function* () { } if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + settingsFileTrusted = false; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { path: settingsPath, @@ -490,9 +610,39 @@ const make = Effect.gen(function* () { ), ); - return foldProviderInstanceEnabledFlags( + const legacyProjectRows = + settings.projectSettingsFolded || !settingsFileTrusted + ? [] + : yield* sql` + SELECT + project_id AS "projectId", + default_model_selection_json AS "defaultModelSelection", + default_thread_env_mode AS "defaultThreadEnvMode", + auto_pull AS "autoPull", + scripts_json AS "scripts" + FROM projection_projects + WHERE deleted_at IS NULL + `.pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-project-settings", + cause, + }), + ), + ); + + const loaded = foldProviderInstanceEnabledFlags( restoreUsedProviders(settings, persisted, providerHistory), ); + const folded = settingsFileTrusted + ? foldLegacyProjectSettings(loaded, legacyProjectRows) + : loaded; + if (folded !== loaded) { + yield* writeSettingsAtomically(folded); + } + return folded; }); const settingsCache = yield* Cache.make({ @@ -738,30 +888,6 @@ const make = Effect.gen(function* () { }; }); - const writeSettingsAtomically = Effect.fnUntraced( - function* (settings: ServerSettings) { - const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, - ); - - return yield* writeFileStringAtomically({ - filePath: settingsPath, - contents: `${sparseSettingsJson}\n`, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathService), - ); - }, - Effect.mapError( - (cause) => - new ServerSettingsError({ - settingsPath, - operation: "write-file", - cause, - }), - ), - ); - const revalidateAndEmit = writeSemaphore.withPermits(1)( Effect.gen(function* () { yield* Cache.invalidate(settingsCache, cacheKey); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index b9fc9e7ee3ab..6668cc6a0ff5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,7 +22,7 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; -import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -160,7 +160,7 @@ export const autoPullPolicyLayer = Layer.effect( const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); if (project._tag === "None") return false; const settings = yield* serverSettings.getSettings; - return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + return resolveProjectSettings(settings, project.value.id).settings.defaultAutoPull; }, Effect.orElseSucceed(() => false), ), diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index fc5ffe62ebc4..3e9934f6f782 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId, + ProjectId, ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; @@ -43,6 +44,17 @@ describe("supportsSharedSettingsSync", () => { }); describe("splitSharedServerPatch", () => { + it("keeps project overrides local: project ids belong to one environment", () => { + const patch = { + projectSettingsOverrides: { [ProjectId.make("project")]: { defaultAutoPull: true } }, + sidebarAutoSettleOnMerge: false, + }; + expect(splitSharedServerPatch(patch)).toEqual({ + sharedPatch: { sidebarAutoSettleOnMerge: false }, + localPatch: { projectSettingsOverrides: patch.projectSettingsOverrides }, + }); + }); + it.each([ { instanceId: ProviderInstanceId.make("codex"), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 9dcc844e713a..a57f30f95e55 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -99,6 +99,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), /** Server persists the opt-in for continuing interrupted threads after restarts. */ threadRestartContinuation: Schema.optionalKey(Schema.Boolean), + /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ + projectSettingsOverrides: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dd6136461fc1..90b176c3095a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -928,6 +928,55 @@ export const BackgroundActivitySettings = Schema.Struct({ }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; +/** + * Server settings a project may override. Every other server setting is + * environment-wide: providers, keybindings, observability, device hosts, + * background activity, theme. UI, search and the write planner derive + * eligibility from this list, so adding a key here is the whole opt-in. + */ +export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ + "defaultModelSelection", + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "defaultAutoPull", + "defaultProjectScripts", + "enableAgentBrowserAccess", + "enableAgentDeviceAccess", + "textGenerationModelSelection", + "sourceControlWriterModelSelection", + "sourceControlWritingStyle", + "pullRequestMergeMethod", + "sidebarAutoSettleOnMerge", + "sidebarAutoSettleAfterDays", + "continueThreadsAfterServerUpdate", + "enableLegacyTokenStreaming", +] as const; +export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; + +/** + * One project's overrides. An absent key inherits the environment value; + * `null` is a real value where the environment type is nullable (no default + * model, no dedicated writer model, never auto-settle). + */ +export const ProjectSettingsOverrides = Schema.Struct({ + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), + textGenerationModelSelection: Schema.optionalKey(ModelSelection), + sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), + pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), + sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), + enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), +} satisfies Record); +export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; + export const ServerSettings = Schema.Struct({ // Legacy token-by-token assistant output. Deliberately a fresh key (was // `enableAssistantStreaming`): decoding drops the old key, so everyone, @@ -968,6 +1017,21 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * Per-project overrides of the keys in `PROJECT_SCOPED_SERVER_SETTING_KEYS`. + * The source of truth for project settings; `projectAgentBrowserAccessOverrides`, + * `projectAutoPullOverrides` and `projectScriptOverrides` are derived views + * kept for one release so older clients keep reading them. + */ + projectSettingsOverrides: Schema.Record(ProjectId, ProjectSettingsOverrides).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + /** + * Whether the legacy per-project fields have been folded into + * `projectSettingsOverrides`. The fold runs once so a later reset in the + * settings UI is not undone by the next server start. + */ + projectSettingsFolded: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** * Whether agents may drive simulators and emulators. Gates the `device_*` * MCP tools and the preconfigured `agent-device` CLI the same way @@ -1053,6 +1117,14 @@ export const ServerSettings = Schema.Struct({ sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * The merge method pull requests start with; `null` reuses the method + * last chosen on this device. Server-side so a project can override it + * like any other project setting. + */ + pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration @@ -1140,6 +1212,7 @@ export const ServerSettingsOperation = Schema.Literals([ "check-exists", "read-file", "read-provider-history", + "read-project-settings", "read-secret", "remove-secret", "remove-stale-secret", @@ -1257,6 +1330,16 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + /** + * Per-project entry replacement: each entry replaces that project's whole + * override set and `null` removes it. Clearing one override means resending + * the entry without that key. Per-key null cannot express "clear" for the + * keys whose value type is itself nullable, and clients always hold the + * current entry from the last settings snapshot. + */ + projectSettingsOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), + ), enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), @@ -1287,6 +1370,7 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), diff --git a/packages/shared/package.json b/packages/shared/package.json index b502090e2a9d..bda80d937b92 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" }, + "./projectSettings": { + "types": "./src/projectSettings.ts", + "import": "./src/projectSettings.ts" + }, "./threadEnvMode": { "types": "./src/threadEnvMode.ts", "import": "./src/threadEnvMode.ts" diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 4d98e36b4d70..5cb988753a39 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,23 +1,41 @@ import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; -/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +type ProjectScriptSettings = Pick< + ServerSettings, + | "defaultProjectScripts" + | "projectScriptOverrides" + | "projectSettingsOverrides" + | "projectSettingsFolded" +>; + +/** + * The project's override wins, then environment defaults. Until the legacy + * fields have been folded into `projectSettingsOverrides`, the old map (null + * there meant "reset to machine defaults") and the aggregate's own scripts + * still count, so a server that has not run the fold yet behaves as before. + */ export function resolveProjectScripts( - settings: Pick, + settings: ProjectScriptSettings, project: { id: ProjectId; scripts: readonly ProjectScript[] }, ): readonly ProjectScript[] { - const override = settings.projectScriptOverrides[project.id]; - if (override === null) return settings.defaultProjectScripts; - return ( - override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) - ); + const override = settings.projectSettingsOverrides[project.id]?.defaultProjectScripts; + if (override !== undefined) return override; + if (settings.projectSettingsFolded) return settings.defaultProjectScripts; + const legacy = settings.projectScriptOverrides[project.id]; + if (legacy === null) return settings.defaultProjectScripts; + return legacy ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts); } export function projectScriptsInheritDefaults( - settings: Pick, + settings: ProjectScriptSettings, project: { id: ProjectId; scripts: readonly ProjectScript[] }, ): boolean { - const override = settings.projectScriptOverrides[project.id]; - return override === null || (override === undefined && project.scripts.length === 0); + if (settings.projectSettingsOverrides[project.id]?.defaultProjectScripts !== undefined) { + return false; + } + if (settings.projectSettingsFolded) return true; + const legacy = settings.projectScriptOverrides[project.id]; + return legacy === null || (legacy === undefined && project.scripts.length === 0); } interface ProjectScriptRuntimeEnvInput { diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts new file mode 100644 index 000000000000..950867d36dc7 --- /dev/null +++ b/packages/shared/src/projectSettings.test.ts @@ -0,0 +1,208 @@ +import { + DEFAULT_SERVER_SETTINGS, + PROJECT_SCOPED_SERVER_SETTING_KEYS, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { createModelSelection } from "./model.ts"; +import { + clearProjectSettingsOverrides, + hasProjectSettingsOverrides, + resolveProjectSettings, + withProjectSettingsOverrides, +} from "./projectSettings.ts"; +import { applyServerSettingsPatch } from "./serverSettings.ts"; + +const projectId = ProjectId.make("project-a"); +const otherProjectId = ProjectId.make("project-b"); + +describe("resolveProjectSettings", () => { + it("inherits every scopable key when the project has no overrides", () => { + const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId); + expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS); + for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { + expect(resolved.sources[key]).toBe("environment"); + } + expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null).settings).toBe( + DEFAULT_SERVER_SETTINGS, + ); + }); + + it("applies overrides per key and reports their source", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + sidebarAutoSettleAfterDays: 3, + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: false, sidebarAutoSettleAfterDays: null }, + }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.defaultAutoPull).toBe(false); + expect(resolved.settings.sidebarAutoSettleAfterDays).toBeNull(); + expect(resolved.settings.defaultThreadEnvMode).toBe(settings.defaultThreadEnvMode); + expect(resolved.sources.defaultAutoPull).toBe("project"); + expect(resolved.sources.sidebarAutoSettleAfterDays).toBe("project"); + expect(resolved.sources.defaultThreadEnvMode).toBe("environment"); + expect(resolveProjectSettings(settings, otherProjectId).settings.defaultAutoPull).toBe(true); + }); + + it("keeps the environment text generation model when the override's provider is disabled", () => { + const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providers: { claudeAgent: { enabled: false } }, + projectSettingsOverrides: { + [projectId]: { textGenerationModelSelection: disabledSelection }, + }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.textGenerationModelSelection).toEqual( + settings.textGenerationModelSelection, + ); + expect(resolved.sources.textGenerationModelSelection).toBe("environment"); + }); + + it("honours the aggregate's own fields only until the server has folded them", () => { + const aggregateModel = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5"); + const project = { + defaultModelSelection: aggregateModel, + defaultThreadEnvMode: "local" as const, + }; + const unfolded = resolveProjectSettings( + { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: false }, + projectId, + project, + ); + expect(unfolded.settings.defaultModelSelection).toEqual(aggregateModel); + expect(unfolded.settings.defaultThreadEnvMode).toBe("local"); + expect(unfolded.sources.defaultModelSelection).toBe("project"); + // A stored override still beats the aggregate before the fold. + const overridden = resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsFolded: false, + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } }, + }, + projectId, + project, + ); + expect(overridden.settings.defaultThreadEnvMode).toBe("worktree"); + // After the fold a reset in the record wins over the stale aggregate. + const folded = resolveProjectSettings( + { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }, + projectId, + project, + ); + expect(folded.settings.defaultModelSelection).toBeNull(); + expect(folded.sources.defaultModelSelection).toBe("environment"); + }); + + it("keeps the environment default model when the override's provider is disabled", () => { + const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + providers: { claudeAgent: { enabled: false } }, + projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, + }); + const resolved = resolveProjectSettings(settings, projectId); + expect(resolved.settings.defaultModelSelection).toBeNull(); + expect(resolved.sources.defaultModelSelection).toBe("environment"); + }); +}); + +describe("projectSettingsOverrides patches", () => { + it("replaces a project's entry, removes it with null, and drops empty entries", () => { + const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + [otherProjectId]: { defaultAutoPull: false }, + }, + }); + expect(hasProjectSettingsOverrides(first)).toBe(true); + const replaced = applyServerSettingsPatch(first, { + projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } }, + }); + expect(replaced.projectSettingsOverrides[projectId]).toEqual({ + enableAgentBrowserAccess: false, + }); + expect(replaced.projectSettingsOverrides[otherProjectId]).toEqual({ defaultAutoPull: false }); + const emptied = applyServerSettingsPatch(replaced, { + projectSettingsOverrides: { [projectId]: {} }, + }); + expect(emptied.projectSettingsOverrides[projectId]).toBeUndefined(); + const removed = applyServerSettingsPatch(replaced, { + projectSettingsOverrides: { [projectId]: null }, + }); + expect(removed.projectSettingsOverrides).toEqual({ + [otherProjectId]: { defaultAutoPull: false }, + }); + expect(hasProjectSettingsOverrides(DEFAULT_SERVER_SETTINGS)).toBe(false); + }); + + it("derives the legacy per-key maps from the generic record", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + [otherProjectId]: { defaultProjectScripts: [] }, + }, + }); + expect(settings.projectAutoPullOverrides).toEqual({ [projectId]: true }); + expect(settings.projectAgentBrowserAccessOverrides).toEqual({ [projectId]: false }); + expect(settings.projectScriptOverrides).toEqual({ [otherProjectId]: [] }); + }); + + it("translates legacy per-key patches into the generic record", () => { + const written = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAutoPullOverrides: { [projectId]: true }, + projectAgentBrowserAccessOverrides: { [projectId]: false, [otherProjectId]: true }, + }); + expect(written.projectSettingsOverrides).toEqual({ + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + [otherProjectId]: { enableAgentBrowserAccess: true }, + }); + const cleared = applyServerSettingsPatch(written, { + projectAgentBrowserAccessOverrides: { [projectId]: null, [otherProjectId]: null }, + }); + expect(cleared.projectSettingsOverrides).toEqual({ [projectId]: { defaultAutoPull: true } }); + }); + + it("lets a canonical entry win over a legacy map for the same project", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } }, + }); + // The canonical entry omits defaultAutoPull to clear it; the stale legacy + // map in the same patch must not put it back. + const next = applyServerSettingsPatch(current, { + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } }, + projectAutoPullOverrides: { [projectId]: true, [otherProjectId]: false }, + }); + expect(next.projectSettingsOverrides).toEqual({ + [projectId]: { defaultThreadEnvMode: "local" }, + [otherProjectId]: { defaultAutoPull: false }, + }); + }); + + it("builds replacement entries and clears individual keys", () => { + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false }, + }, + }); + expect(clearProjectSettingsOverrides(settings, projectId, ["defaultAutoPull"])).toEqual({ + enableAgentBrowserAccess: false, + }); + expect( + clearProjectSettingsOverrides(settings, projectId, [ + "defaultAutoPull", + "enableAgentBrowserAccess", + ]), + ).toBeNull(); + expect(clearProjectSettingsOverrides(settings, otherProjectId, ["defaultAutoPull"])).toBeNull(); + expect(withProjectSettingsOverrides(settings, projectId, null)).toEqual({}); + expect( + withProjectSettingsOverrides(settings, otherProjectId, { defaultThreadEnvMode: "worktree" }), + ).toEqual({ + ...settings.projectSettingsOverrides, + [otherProjectId]: { defaultThreadEnvMode: "worktree" }, + }); + }); +}); diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts new file mode 100644 index 000000000000..742e1cb8d140 --- /dev/null +++ b/packages/shared/src/projectSettings.ts @@ -0,0 +1,124 @@ +import { + type ModelSelection, + PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectId, + type ProjectScopedServerSettingKey, + type ProjectSettingsOverrides, + type ServerSettings, + type ThreadEnvMode, +} from "@t3tools/contracts"; +import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; + +export type ProjectSettingSource = "environment" | "project"; + +export type ProjectSettingSources = Readonly< + Record +>; + +export interface ResolvedProjectSettings { + /** Environment settings with the project's overrides applied. */ + readonly settings: ServerSettings; + /** Where each scopable key's effective value came from. */ + readonly sources: ProjectSettingSources; + /** The project's raw override entry; `{}` when it has none. */ + readonly overrides: ProjectSettingsOverrides; +} + +const EMPTY_OVERRIDES: ProjectSettingsOverrides = {}; + +const ENVIRONMENT_SOURCES: ProjectSettingSources = Object.fromEntries( + PROJECT_SCOPED_SERVER_SETTING_KEYS.map((key) => [key, "environment"]), +) as Record; + +/** Cheap check so hot paths skip the projectId lookup when nothing is overridden. */ +export function hasProjectSettingsOverrides( + settings: Pick, +): boolean { + for (const entry of Object.values(settings.projectSettingsOverrides)) { + if (Object.keys(entry).length > 0) return true; + } + return false; +} + +/** + * The project aggregate's own model and workspace fields. They remain the + * source of truth until the server has folded them into the override record; + * after the fold the record alone decides, so a reset there cannot be undone + * by a stale aggregate value. + */ +export interface LegacyProjectSettingsFields { + readonly defaultModelSelection?: ModelSelection | null | undefined; + readonly defaultThreadEnvMode?: ThreadEnvMode | null | undefined; +} + +/** + * Apply one project's overrides on top of environment settings. A model + * override whose provider is disabled on this environment falls back to the + * environment value, the same guard the environment-level selection gets. + */ +export function resolveProjectSettings( + settings: ServerSettings, + projectId: ProjectId | null, + project?: LegacyProjectSettingsFields, +): ResolvedProjectSettings { + const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId]; + const overrides: ProjectSettingsOverrides = + project === undefined || settings.projectSettingsFolded + ? (stored ?? EMPTY_OVERRIDES) + : { + ...(project.defaultModelSelection != null + ? { defaultModelSelection: project.defaultModelSelection } + : {}), + ...(project.defaultThreadEnvMode != null + ? { defaultThreadEnvMode: project.defaultThreadEnvMode } + : {}), + ...stored, + }; + if (Object.keys(overrides).length === 0) { + return { settings, sources: ENVIRONMENT_SOURCES, overrides: EMPTY_OVERRIDES }; + } + const sources: Record = { + ...ENVIRONMENT_SOURCES, + }; + const effective: Record = { ...settings }; + for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { + if (!Object.hasOwn(overrides, key)) continue; + const value = overrides[key]; + // A model on a disabled provider falls back to the environment, like the + // environment-level guards do for these keys. + if ( + (key === "textGenerationModelSelection" || key === "defaultModelSelection") && + value !== undefined && + value !== null && + !isModelSelectionProviderEnabled(settings, value as ModelSelection) + ) { + continue; + } + effective[key] = value; + sources[key] = "project"; + } + return { settings: effective as ServerSettings, sources, overrides }; +} + +/** Replace the project's entry, dropping it entirely when nothing is overridden. */ +export function withProjectSettingsOverrides( + settings: Pick, + projectId: ProjectId, + next: ProjectSettingsOverrides | null, +): ServerSettings["projectSettingsOverrides"] { + const { [projectId]: _removed, ...rest } = settings.projectSettingsOverrides; + return next === null || Object.keys(next).length === 0 ? rest : { ...rest, [projectId]: next }; +} + +/** The project's entry with `keys` removed; `null` when that leaves it empty. */ +export function clearProjectSettingsOverrides( + settings: Pick, + projectId: ProjectId, + keys: readonly ProjectScopedServerSettingKey[], +): ProjectSettingsOverrides | null { + const current = settings.projectSettingsOverrides[projectId]; + if (current === undefined) return null; + const next = { ...current }; + for (const key of keys) delete next[key]; + return Object.keys(next).length === 0 ? null : next; +} diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index cc783fe64bf3..2658db3346e6 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -20,6 +20,9 @@ import { resolveProjectAutoPull, } from "./serverSettings.ts"; +/** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ +const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; + describe("serverSettings helpers", () => { it("replaces SSH host lists when saving, editing, and removing hosts", () => { const host = { id: "mini", label: "Mac mini", target: "mini" }; @@ -40,14 +43,19 @@ describe("serverSettings helpers", () => { icon: "play" as const, runOnWorktreeCreate: false, }; - const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + // Before the one-time fold, scripts stored on the project aggregate still apply. + const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(unfolded, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(unfolded, existing)).toBe(false); + const defaults = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, { defaultProjectScripts: [action], }); expect(resolveProjectScripts(defaults, project)).toEqual([action]); expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); - const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; - expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); - expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + expect(resolveProjectScripts(defaults, existing)).toEqual([action]); const disabled = applyServerSettingsPatch(defaults, { projectScriptOverrides: { [project.id]: [] }, }); @@ -82,7 +90,7 @@ describe("serverSettings helpers", () => { }; const firstAction = { ...defaultAction, command: "npm run lint" }; const secondAction = { ...defaultAction, command: "npm run build" }; - const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, { defaultProjectScripts: [defaultAction], projectScriptOverrides: { [firstProject.id]: [firstAction] }, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index f969e4412c30..bb209fd812bd 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -4,6 +4,8 @@ import { resolveProviderInstanceEnabled, type ModelSelection, type ProjectId, + type ProjectScopedServerSettingKey, + type ProjectSettingsOverrides, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -24,22 +26,33 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +/** @deprecated Read `resolveProjectSettings(...).settings.enableAgentBrowserAccess`. */ export function resolveProjectAgentBrowserAccess( - settings: Pick, + settings: Pick< + ServerSettings, + "enableAgentBrowserAccess" | "projectAgentBrowserAccessOverrides" | "projectSettingsOverrides" + >, projectId: ProjectId, ): boolean { return ( - settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + settings.projectSettingsOverrides[projectId]?.enableAgentBrowserAccess ?? + settings.projectAgentBrowserAccessOverrides[projectId] ?? + settings.enableAgentBrowserAccess ); } +/** @deprecated Read `resolveProjectSettings(...).settings.defaultAutoPull`. */ export function resolveProjectAutoPull( - settings: Pick, + settings: Pick< + ServerSettings, + "defaultAutoPull" | "projectAutoPullOverrides" | "projectSettingsOverrides" + >, projectId: ProjectId, legacyAutoPull: boolean | undefined, ): boolean { // Existing opt-ins stay enabled until explicitly overridden or reset. return ( + settings.projectSettingsOverrides[projectId]?.defaultAutoPull ?? settings.projectAutoPullOverrides[projectId] ?? (legacyAutoPull === true || settings.defaultAutoPull) ); @@ -160,10 +173,97 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +/** + * Derived views of `projectSettingsOverrides` for clients that still read + * the legacy per-key maps. Recomputed on every patch and load so they + * cannot drift from the generic record. + */ +export function deriveLegacyProjectOverrides( + settings: Pick, +): Pick< + ServerSettings, + "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides" | "projectScriptOverrides" +> { + const projectAgentBrowserAccessOverrides: Record = {}; + const projectAutoPullOverrides: Record = {}; + const projectScriptOverrides: Record = {}; + for (const [projectId, entry] of Object.entries(settings.projectSettingsOverrides)) { + if (entry.enableAgentBrowserAccess !== undefined) { + projectAgentBrowserAccessOverrides[projectId] = entry.enableAgentBrowserAccess; + } + if (entry.defaultAutoPull !== undefined) { + projectAutoPullOverrides[projectId] = entry.defaultAutoPull; + } + if (entry.defaultProjectScripts !== undefined) { + projectScriptOverrides[projectId] = entry.defaultProjectScripts; + } + } + return { projectAgentBrowserAccessOverrides, projectAutoPullOverrides, projectScriptOverrides }; +} + +/** + * Rewrite a patch that still uses the legacy per-key project maps into + * entries of `projectSettingsOverrides`, so older clients keep editing the + * values the server actually reads. `null` in a legacy map clears that one + * override. + */ +function translateLegacyProjectOverridePatch( + current: Pick, + patch: ServerSettingsPatch, +): ServerSettingsPatch { + const { + projectAgentBrowserAccessOverrides, + projectAutoPullOverrides, + projectScriptOverrides, + ...rest + } = patch; + if ( + projectAgentBrowserAccessOverrides === undefined && + projectAutoPullOverrides === undefined && + projectScriptOverrides === undefined + ) { + return patch; + } + const currentEntries: Readonly> = + current.projectSettingsOverrides; + const entries = new Map( + Object.entries(rest.projectSettingsOverrides ?? {}), + ); + // A canonical entry in the same patch is the newer representation; a legacy + // map must not resurrect a key that entry deliberately omits. + const canonicalProjectIds = new Set(Object.keys(rest.projectSettingsOverrides ?? {})); + const applyKey = ( + map: Readonly> | undefined, + key: K, + ) => { + if (map === undefined) return; + for (const [projectId, value] of Object.entries(map)) { + if (canonicalProjectIds.has(projectId)) continue; + const entry: ProjectSettingsOverrides = { + ...(entries.get(projectId) ?? currentEntries[projectId] ?? {}), + }; + if (value === null || value === undefined) { + delete entry[key]; + } else { + entry[key] = value; + } + entries.set(projectId, Object.keys(entry).length === 0 ? null : entry); + } + }; + applyKey(projectAgentBrowserAccessOverrides, "enableAgentBrowserAccess"); + applyKey(projectAutoPullOverrides, "defaultAutoPull"); + applyKey(projectScriptOverrides, "defaultProjectScripts"); + return { + ...rest, + projectSettingsOverrides: Object.fromEntries(entries), + } as ServerSettingsPatch; +} + export function applyServerSettingsPatch( current: ServerSettings, - patch: ServerSettingsPatch, + rawPatch: ServerSettingsPatch, ): ServerSettings { + const patch = translateLegacyProjectOverridePatch(current, rawPatch); const selectionPatch = patch.textGenerationModelSelection; const { automaticGitFetchInterval, @@ -173,8 +273,13 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, - projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, - projectAutoPullOverrides: projectAutoPullOverridesPatch, + // Entry replacement: deepMerge would keep keys the client meant to clear. + projectSettingsOverrides: projectSettingsOverridesPatch, + // Already translated into `projectSettingsOverrides` above; the legacy + // maps are derived views and must never be merged directly. + projectAgentBrowserAccessOverrides: _legacyBrowserAccess, + projectAutoPullOverrides: _legacyAutoPull, + projectScriptOverrides: _legacyScripts, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -231,19 +336,12 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), - ...(projectAgentBrowserAccessOverridesPatch !== undefined + ...(projectSettingsOverridesPatch !== undefined ? { - projectAgentBrowserAccessOverrides: mergeSettingsEntries( - current.projectAgentBrowserAccessOverrides, - projectAgentBrowserAccessOverridesPatch, - ), - } - : {}), - ...(projectAutoPullOverridesPatch !== undefined - ? { - projectAutoPullOverrides: mergeSettingsEntries( - current.projectAutoPullOverrides, - projectAutoPullOverridesPatch, + projectSettingsOverrides: Object.fromEntries( + Object.entries( + mergeSettingsEntries(current.projectSettingsOverrides, projectSettingsOverridesPatch), + ).filter(([, entry]) => Object.keys(entry).length > 0), ), } : {}), @@ -253,14 +351,6 @@ export function applyServerSettingsPatch( ...(patch.defaultProjectScripts !== undefined ? { defaultProjectScripts: patch.defaultProjectScripts } : {}), - ...(patch.projectScriptOverrides !== undefined - ? { - projectScriptOverrides: { - ...current.projectScriptOverrides, - ...patch.projectScriptOverrides, - }, - } - : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( @@ -291,6 +381,7 @@ export function applyServerSettingsPatch( ); const nextWithReplacements = { ...nextWithReplacementsBase, + ...deriveLegacyProjectOverrides(nextWithReplacementsBase), backgroundActivity: normalizedBackgroundActivity, automaticGitFetchInterval: resolvedBackgroundActivity.automaticGitFetchInterval, providerHealthRefreshInterval: resolvedBackgroundActivity.providerHealthRefreshInterval, From 8b2c0465def151f022e01dd04b1b6ba3859bd714 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 13:56:39 -0700 Subject: [PATCH 05/26] feat(web): pick settings environment and project as two selects (#10636) --- .../settings/ProjectSettingsPanel.tsx | 71 +++--- .../components/settings/ProjectsSettings.tsx | 179 +++------------ .../SettingsScopeSelects.logic.test.ts | 88 +++++++ .../settings/SettingsScopeSelects.logic.ts | 55 +++++ .../settings/SettingsScopeSelects.tsx | 154 +++++++++++++ .../components/settings/settingsScope.test.ts | 216 ++++++++++++++++++ .../src/components/settings/settingsScope.ts | 163 +++++++++++++ .../settings/useSettingsProjectGroups.ts | 24 ++ apps/web/src/routes/settings.projects.tsx | 15 +- 9 files changed, 764 insertions(+), 201 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts create mode 100644 apps/web/src/components/settings/SettingsScopeSelects.logic.ts create mode 100644 apps/web/src/components/settings/SettingsScopeSelects.tsx create mode 100644 apps/web/src/components/settings/settingsScope.test.ts create mode 100644 apps/web/src/components/settings/settingsScope.ts create mode 100644 apps/web/src/components/settings/useSettingsProjectGroups.ts diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d88644fb7e3c..a597e852c085 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -68,7 +68,6 @@ import { } from "../../providerInstances"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { - buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; @@ -115,6 +114,7 @@ import { ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ @@ -128,31 +128,6 @@ export const PROJECT_GROUPING_MODE_LABELS: Record - new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ), - [environments], - ); - return useMemo( - () => - buildSidebarProjectSnapshots({ - projects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - }).sort((a, b) => a.displayName.localeCompare(b.displayName)), - [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], - ); -} - function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } @@ -160,9 +135,11 @@ function memberKey(member: { environmentId: string; id: string }): string { export function ProjectSettingsPanel({ projectKey, environmentId = null, + checkoutKey = null, }: { projectKey: string; environmentId?: EnvironmentId | null; + checkoutKey?: string | null; }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); @@ -171,9 +148,11 @@ export function ProjectSettingsPanel({ const members = useMemo( () => selected?.memberProjects.filter( - (member) => environmentId === null || member.environmentId === environmentId, + (member) => + (environmentId === null || member.environmentId === environmentId) && + (checkoutKey === null || member.physicalProjectKey === checkoutKey), ) ?? [], - [selected, environmentId], + [selected, environmentId, checkoutKey], ); // Remember the members of the last rendered group so a grouping-rule change @@ -181,6 +160,7 @@ export function ProjectSettingsPanel({ const lastSelectionRef = useRef<{ key: string; environmentId: EnvironmentId | null; + checkoutKey: string | null; memberKeys: string[]; } | null>(null); useEffect(() => { @@ -188,28 +168,38 @@ export function ProjectSettingsPanel({ lastSelectionRef.current = { key: selected.projectKey, environmentId, + checkoutKey, memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected, members, environmentId]); + }, [selected, members, environmentId, checkoutKey]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey || last.environmentId !== environmentId) return; + if ( + last?.key !== projectKey || + last.environmentId !== environmentId || + last.checkoutKey !== checkoutKey + ) + return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/settings/projects", - search: { project: successor.projectKey, machine: environmentId ?? undefined }, + search: { + project: successor.projectKey, + machine: environmentId ?? undefined, + checkout: checkoutKey ?? undefined, + }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, members.length, environmentId]); + }, [groups, navigate, projectKey, members.length, environmentId, checkoutKey]); if (!selected) { return ( @@ -223,7 +213,7 @@ export function ProjectSettingsPanel({ if (members.length === 0) return (

- This project has no checkout on this machine. + This checkout is no longer available in the selected project and environment.

); const scopedGroup = { @@ -234,7 +224,7 @@ export function ProjectSettingsPanel({ }; return ( @@ -879,23 +869,14 @@ function ProjectDetail({ draftStore.clearProjectDraftThreadId(projectRef); } - if (isWholeGroup) { - if (hasOtherMembers) { - void navigate({ - to: "/settings/projects", - search: { project: group.projectKey, machine: undefined }, - replace: true, - }); - } else { - void navigate({ to: "/", replace: true }); - } + if (isWholeGroup && !hasOtherMembers) { + void navigate({ to: "/", replace: true }); } }, [ deleteProject, group.displayName, group.memberProjects.length, - group.projectKey, hasOtherMembers, navigate, reportFailure, diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index acc7326ac866..a0ff91f03012 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -1,168 +1,53 @@ -import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { ChevronDownIcon, FolderIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; -import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { ProjectFavicon } from "../ProjectFavicon"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { useEnvironments } from "../../state/environments"; -import { Toggle, ToggleGroup } from "../ui/toggle-group"; -import { - Combobox, - ComboboxEmpty, - ComboboxSearchInput, - ComboboxItem, - ComboboxList, - ComboboxPopup, - ComboboxTrigger, -} from "../ui/combobox"; -import { selectTriggerVariants } from "../ui/select"; -import { cn } from "../../lib/utils"; -import { ProjectSettingsPanel, useSettingsProjectGroups } from "./ProjectSettingsPanel"; +import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; - -function ScopePicker({ - label, - value, - options, - onChange, -}: { - label: "project" | "machine"; - value: string | null; - options: ReadonlyArray<{ value: string; label: string; icon?: ReactNode }>; - onChange: (value: string | null) => void; -}) { - const [query, setQuery] = useState(""); - const selected = options.find((option) => option.value === value); - const allIcon = - label === "project" ? : null; - const items = [{ value: "all", label: `All ${label}s`, icon: allIcon }, ...options]; - return ( - item.value === (value ?? "all")) ?? null} - inputValue={query} - onInputValueChange={setQuery} - onOpenChange={() => setQuery("")} - onValueChange={(next) => { - if (next) onChange(next.value === "all" ? null : next.value); - }} - > - - - {value === null ? allIcon : selected?.icon} - - {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} - - - - - - - No matching {label}s. - - {(item: (typeof items)[number]) => ( - - {item.icon} - {item.label} - - )} - - - - ); -} +import { SettingsScopeSelects } from "./SettingsScopeSelects"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; export function ProjectsSettings({ - projectKey, - machineId, + value, onScopeChange, }: { - projectKey: string | null; - machineId: string | null; - onScopeChange: (project: string | null, machine: string | null) => void; + value: SettingsScopeSearch; + onScopeChange: (scope: SettingsScopeSearch) => void; }) { const groups = useSettingsProjectGroups(); const { environments } = useEnvironments(); - const machine = environments.find((environment) => environment.environmentId === machineId); - const machineOptions = environments.map((environment) => ({ - value: environment.environmentId, - label: environment.label, - icon: ( - - ), - })); + const scope = resolveSettingsScope(value, groups, environments); + // The panel follows remembered members when grouping replaces a project key. + const projectScope = + scope.kind === "project" || + scope.kind === "checkout" || + (scope.kind === "unavailable" && + (scope.reason === "project-missing" || scope.reason === "checkout-missing")); return (
- -
- {environments.length > 3 ? ( - onScopeChange(projectKey, value)} - /> - ) : ( - { - const value = next[0]; - if (value) onScopeChange(projectKey, value === "all" ? null : value); - }} - > - All machines - {machineOptions.map((option) => ( - - {option.icon} - {option.label} - - ))} - - )} -
- ({ - value: group.projectKey, - label: group.displayName, - icon: , - }))} - onChange={(value) => onScopeChange(value, machineId)} - /> -
-
+ +
- {machineId !== null && !machine ? ( -

This machine is no longer available.

- ) : projectKey === null ? ( - - ) : ( + {value.project && projectScope ? ( + ) : scope.kind === "unavailable" ? ( +

{scope.message}

+ ) : ( + )}
); } +import { EnvironmentId } from "@t3tools/contracts"; diff --git a/apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts b/apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts new file mode 100644 index 000000000000..d8e123631e61 --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeSelects.logic.test.ts @@ -0,0 +1,88 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + environmentAxisValue, + projectAxisValue, + selectEnvironmentAxis, + selectProjectAxis, + settingsScopeEnvironmentLabel, +} from "./SettingsScopeSelects.logic"; + +const first = { + environmentId: EnvironmentId.make("first"), + label: "Development", + displayUrl: "https://first.example.com", +}; +const second = { + environmentId: EnvironmentId.make("second"), + label: "Development", + displayUrl: "https://second.example.com", +}; + +describe("settings scope environment labels", () => { + it("distinguishes same-name environments by address", () => { + const environments = [first, second]; + expect( + environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), + ).toEqual([ + "Development · https://first.example.com", + "Development · https://second.example.com", + ]); + }); + + it("falls back to environment IDs when duplicate names have no display URL", () => { + const environments = [first, second].map((environment) => ({ + ...environment, + displayUrl: null, + })); + expect( + environments.map((environment) => settingsScopeEnvironmentLabel(environment, environments)), + ).toEqual(["Development · first", "Development · second"]); + }); + + it("keeps unique names compact and removes disambiguation after a rename", () => { + expect(settingsScopeEnvironmentLabel(first, [first])).toBe("Development"); + expect(settingsScopeEnvironmentLabel(first, [first, { ...second, label: "Production" }])).toBe( + "Development", + ); + }); +}); + +describe("settings scope axes", () => { + it("maps each axis to its search key and back", () => { + expect(projectAxisValue({})).toBe("all"); + expect(projectAxisValue({ project: "app" })).toBe("app"); + expect(selectProjectAxis({ machine: "second" }, "app")).toEqual({ + project: "app", + machine: "second", + }); + expect(selectProjectAxis({ machine: "second", project: "app" }, "all")).toEqual({ + machine: "second", + }); + expect(selectEnvironmentAxis({ project: "app" }, "first")).toEqual({ + project: "app", + machine: "first", + }); + expect(selectEnvironmentAxis({ project: "app", machine: "first" }, "all")).toEqual({ + project: "app", + }); + }); + + it("drops a checkout narrowing from older links when either axis changes", () => { + const checkout = { project: "app", checkout: "app@first", machine: "first" }; + expect(selectEnvironmentAxis(checkout, "second")).toEqual({ + project: "app", + machine: "second", + }); + expect(selectProjectAxis(checkout, "app")).toEqual({ project: "app", machine: "first" }); + }); +}); + +describe("environmentAxisValue", () => { + it("shows the checkout's environment for a legacy checkout link", () => { + expect(environmentAxisValue({ project: "p", checkout: "c" }, "laptop")).toBe("laptop"); + expect(environmentAxisValue({ project: "p" }, null)).toBe("all"); + expect(environmentAxisValue({ machine: "desk" }, "laptop")).toBe("desk"); + }); +}); diff --git a/apps/web/src/components/settings/SettingsScopeSelects.logic.ts b/apps/web/src/components/settings/SettingsScopeSelects.logic.ts new file mode 100644 index 000000000000..166a31a40c05 --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeSelects.logic.ts @@ -0,0 +1,55 @@ +import type { EnvironmentPresentation } from "../../state/environments"; +import type { SettingsScopeSearch } from "./settingsScope"; + +type ScopeEnvironment = Pick; + +export function settingsScopeEnvironmentLabel( + environment: ScopeEnvironment, + environments: readonly ScopeEnvironment[], +) { + const duplicate = environments.some( + (other) => + other.environmentId !== environment.environmentId && other.label === environment.label, + ); + return duplicate + ? `${environment.label} · ${environment.displayUrl ?? environment.environmentId}` + : environment.label; +} + +export const ALL_ENVIRONMENTS_VALUE = "all"; +export const ALL_PROJECTS_VALUE = "all"; + +/** + * The environment axis: `all` or an environment id. A legacy checkout link + * without `machine` still names one environment, which the resolver supplies. + */ +export function environmentAxisValue( + search: SettingsScopeSearch, + resolvedEnvironmentId?: string | null, +): string { + return search.machine ?? resolvedEnvironmentId ?? ALL_ENVIRONMENTS_VALUE; +} + +/** The project axis: `all` or a project key. */ +export function projectAxisValue(search: SettingsScopeSearch): string { + return search.project ?? ALL_PROJECTS_VALUE; +} + +/** Choosing an environment keeps the project; a pre-existing checkout narrowing is dropped. */ +export function selectEnvironmentAxis( + search: SettingsScopeSearch, + value: string, +): SettingsScopeSearch { + const next: SettingsScopeSearch = {}; + if (search.project) next.project = search.project; + if (value !== ALL_ENVIRONMENTS_VALUE) next.machine = value; + return next; +} + +/** Choosing a project keeps the environment axis. */ +export function selectProjectAxis(search: SettingsScopeSearch, value: string): SettingsScopeSearch { + const next: SettingsScopeSearch = {}; + if (value !== ALL_PROJECTS_VALUE) next.project = value; + if (search.machine) next.machine = search.machine; + return next; +} diff --git a/apps/web/src/components/settings/SettingsScopeSelects.tsx b/apps/web/src/components/settings/SettingsScopeSelects.tsx new file mode 100644 index 000000000000..6271a7313c29 --- /dev/null +++ b/apps/web/src/components/settings/SettingsScopeSelects.tsx @@ -0,0 +1,154 @@ +import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { LayersIcon } from "lucide-react"; + +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import type { EnvironmentPresentation } from "../../state/environments"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +import { + ALL_ENVIRONMENTS_VALUE, + ALL_PROJECTS_VALUE, + environmentAxisValue, + projectAxisValue, + selectEnvironmentAxis, + selectProjectAxis, + settingsScopeEnvironmentLabel, +} from "./SettingsScopeSelects.logic"; + +const TRIGGER_CLASSNAME = "w-auto min-w-0 max-w-56 justify-start"; + +/** + * Two independent targets: which environments a change applies to, and + * which project it overrides. A project is the same project on every + * environment; the environment select alone decides where the override is + * written. Each axis defaults to "all". + */ +export function SettingsScopeSelects({ + value, + groups, + environments, + onChange, +}: { + value: SettingsScopeSearch; + groups: readonly SidebarProjectSnapshot[]; + environments: readonly EnvironmentPresentation[]; + onChange: (next: SettingsScopeSearch) => void; +}) { + const resolved = resolveSettingsScope(value, groups, environments); + const environmentValue = environmentAxisValue( + value, + resolved.kind === "checkout" ? resolved.environmentId : null, + ); + const projectValue = projectAxisValue(value); + const selectedEnvironment = environments.find( + (environment) => environment.environmentId === environmentValue, + ); + const selectedGroup = groups.find((group) => group.projectKey === value.project); + + return ( +
+ + + + {resolved.kind === "unavailable" ? ( + {resolved.message} + ) : null} +
+ ); +} diff --git a/apps/web/src/components/settings/settingsScope.test.ts b/apps/web/src/components/settings/settingsScope.test.ts new file mode 100644 index 000000000000..166e7121f57d --- /dev/null +++ b/apps/web/src/components/settings/settingsScope.test.ts @@ -0,0 +1,216 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { + SidebarProjectGroupMember, + SidebarProjectSnapshot, +} from "../../sidebarProjectGrouping"; +import { resolveSettingsScope, validateSettingsScopeSearch } from "./settingsScope"; + +const laptopId = EnvironmentId.make("laptop"); +const serverId = EnvironmentId.make("server"); +const environments = [ + { environmentId: laptopId, label: "Laptop" }, + { environmentId: serverId, label: "Server" }, +]; + +function member(id: string, environmentId: EnvironmentId): SidebarProjectGroupMember { + return { + id: ProjectId.make(id), + environmentId, + title: "T3 Code", + workspaceRoot: `/repos/${id}`, + physicalProjectKey: `${environmentId}:/repos/${id}`, + environmentLabel: + environments.find((environment) => environment.environmentId === environmentId)?.label ?? + null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-07T00:00:00.000Z", + updatedAt: "2026-09-07T00:00:00.000Z", + }; +} + +const first = member("first", laptopId); +const second = member("second", laptopId); +const third = member("third", serverId); +const other = member("other", serverId); + +function group( + projectKey: string, + members: readonly SidebarProjectGroupMember[], +): SidebarProjectSnapshot { + return { + ...members[0]!, + projectKey, + displayName: projectKey, + memberProjects: members, + memberProjectRefs: members.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + groupedProjectCount: members.length, + environmentPresence: "mixed", + allRemoteMembersAreDesktopLocal: false, + allRemoteMembersAreWsl: false, + remoteEnvironmentLabels: [], + }; +} + +const groups = [group("t3code", [first, second, third]), group("other", [other])]; + +describe("settings scope search", () => { + it.each(["device", "all"] as const)( + "clears narrower targets for an explicit %s selection", + (scope) => { + expect( + validateSettingsScopeSearch({ + scope, + project: "t3code", + machine: laptopId, + checkout: first.physicalProjectKey, + }), + ).toEqual({ scope }); + }, + ); + + it("retains legacy project and machine links without inventing an explicit broad scope", () => { + expect( + validateSettingsScopeSearch({ project: "t3code", machine: laptopId, unused: true }), + ).toEqual({ + project: "t3code", + machine: laptopId, + }); + }); + + it("retains an orphan checkout so it cannot turn into all environments", () => { + const search = validateSettingsScopeSearch({ checkout: first.physicalProjectKey }); + expect(search).toEqual({ checkout: first.physicalProjectKey }); + expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ + kind: "unavailable", + reason: "project-required", + members: [], + environmentIds: [], + }); + }); +}); + +describe("settings scope resolution", () => { + it("distinguishes this device from the default aggregate target", () => { + expect(resolveSettingsScope({}, groups, environments)).toMatchObject({ + kind: "all", + environmentIds: [laptopId, serverId], + }); + expect(resolveSettingsScope({ scope: "device" }, groups, environments)).toMatchObject({ + kind: "device", + members: [], + environmentIds: [], + }); + }); + + it("resolves one environment without targeting its project overrides", () => { + expect(resolveSettingsScope({ machine: serverId }, groups, environments)).toMatchObject({ + kind: "environment", + environmentId: serverId, + environmentIds: [serverId], + members: [], + }); + }); + + it("keeps all physical members in a project aggregate, including several on one environment", () => { + expect(resolveSettingsScope({ project: "t3code" }, groups, environments)).toMatchObject({ + kind: "project", + environmentId: null, + members: [first, second, third], + environmentIds: [laptopId, serverId], + }); + }); + + it("preserves legacy project plus machine aggregates with multiple checkouts", () => { + expect( + resolveSettingsScope({ project: "t3code", machine: laptopId }, groups, environments), + ).toMatchObject({ + kind: "project", + environmentId: laptopId, + label: "t3code / Laptop", + members: [first, second], + environmentIds: [laptopId], + }); + }); + + it("narrows a checkout target to exactly one member, deriving its environment when omitted", () => { + expect( + resolveSettingsScope( + { project: "t3code", checkout: second.physicalProjectKey }, + groups, + environments, + ), + ).toMatchObject({ + kind: "checkout", + checkout: second, + environmentId: laptopId, + label: "t3code / Laptop · /repos/second", + members: [second], + environmentIds: [laptopId], + }); + }); + + it.each([ + { project: "missing" }, + { machine: "removed" }, + { project: "t3code", machine: "removed" }, + { project: "t3code", checkout: "deleted" }, + { project: "other", machine: laptopId }, + { project: "other", checkout: first.physicalProjectKey }, + { project: "t3code", machine: serverId, checkout: first.physicalProjectKey }, + ])("never widens an invalid or stale target: %j", (search) => { + expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ + kind: "unavailable", + members: [], + environmentIds: [], + }); + }); + + it("leaves a removed checkout unavailable while sibling checkouts remain", () => { + const search = { + project: "t3code", + machine: laptopId, + checkout: first.physicalProjectKey, + }; + expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ + kind: "checkout", + members: [first], + }); + expect( + resolveSettingsScope(search, [group("t3code", [second, third])], environments), + ).toMatchObject({ kind: "unavailable", members: [], environmentIds: [] }); + }); + + it("does not select another environment after removing a project's last local checkout", () => { + const search = { project: "t3code", machine: laptopId }; + expect(resolveSettingsScope(search, groups, environments)).toMatchObject({ + kind: "project", + members: [first, second], + }); + expect(resolveSettingsScope(search, [group("t3code", [third])], environments)).toMatchObject({ + kind: "unavailable", + members: [], + environmentIds: [], + }); + }); + + it("rejects a cached checkout whose environment was removed", () => { + expect( + resolveSettingsScope( + { project: "t3code", checkout: third.physicalProjectKey }, + groups, + environments.slice(0, 1), + ), + ).toMatchObject({ + kind: "unavailable", + reason: "environment-missing", + members: [], + environmentIds: [], + }); + }); +}); diff --git a/apps/web/src/components/settings/settingsScope.ts b/apps/web/src/components/settings/settingsScope.ts new file mode 100644 index 000000000000..3d860d088520 --- /dev/null +++ b/apps/web/src/components/settings/settingsScope.ts @@ -0,0 +1,163 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { + SidebarProjectGroupMember, + SidebarProjectSnapshot, +} from "../../sidebarProjectGrouping"; +import type { EnvironmentPresentation } from "../../state/environments"; + +export interface SettingsScopeSearch { + scope?: "device" | "all" | undefined; + project?: string | undefined; + machine?: string | undefined; + checkout?: string | undefined; +} + +type ScopeTargets = { + label: string; + members: readonly SidebarProjectGroupMember[]; + environmentIds: readonly EnvironmentId[]; +}; + +export type ResolvedSettingsScope = ScopeTargets & + ( + | { kind: "device" | "all" } + | { kind: "environment"; environmentId: EnvironmentId } + | { + kind: "project"; + group: SidebarProjectSnapshot; + environmentId: EnvironmentId | null; + } + | { + kind: "checkout"; + group: SidebarProjectSnapshot; + checkout: SidebarProjectGroupMember; + environmentId: EnvironmentId; + } + | { + kind: "unavailable"; + reason: "project-required" | "project-missing" | "environment-missing" | "checkout-missing"; + message: string; + } + ); + +/** Explicit broad targets replace narrower selections; stale IDs remain visible to the resolver. */ +export function validateSettingsScopeSearch(raw: Record): SettingsScopeSearch { + if (raw.scope === "device" || raw.scope === "all") return { scope: raw.scope }; + const stringValue = (value: unknown) => + typeof value === "string" && value.trim().length > 0 ? value : undefined; + const project = stringValue(raw.project); + const machine = stringValue(raw.machine); + const checkout = stringValue(raw.checkout); + return { + ...(project === undefined ? {} : { project }), + ...(machine === undefined ? {} : { machine }), + ...(checkout === undefined ? {} : { checkout }), + }; +} + +/** Resolves only existing targets. An unavailable selection never broadens a subsequent write. */ +export function resolveSettingsScope( + search: SettingsScopeSearch, + groups: readonly SidebarProjectSnapshot[], + environments: readonly Pick[], +): ResolvedSettingsScope { + const unavailable = ( + reason: Extract["reason"], + message: string, + ): ResolvedSettingsScope => ({ + kind: "unavailable", + reason, + label: "Unavailable selection", + message, + members: [], + environmentIds: [], + }); + + if (search.scope === "device") { + return { kind: "device", label: "This device", members: [], environmentIds: [] }; + } + if (search.scope === "all") { + return { + kind: "all", + label: "All environments", + members: [], + environmentIds: environments.map((environment) => environment.environmentId), + }; + } + if (search.checkout && !search.project) { + return unavailable("project-required", "Select a project to choose one of its checkouts."); + } + + const environment = environments.find((candidate) => candidate.environmentId === search.machine); + if (search.machine && !environment) { + return unavailable("environment-missing", "This environment is no longer available."); + } + + if (search.project) { + const group = groups.find((candidate) => candidate.projectKey === search.project); + if (!group) return unavailable("project-missing", "This project is no longer available."); + const members = group.memberProjects.filter( + (member) => + (search.machine === undefined || member.environmentId === search.machine) && + (search.checkout === undefined || member.physicalProjectKey === search.checkout), + ); + if (members.length === 0) { + return unavailable( + "checkout-missing", + search.checkout + ? "This checkout is no longer available in the selected project and environment." + : "This project has no checkout on this environment.", + ); + } + if (search.checkout) { + const checkout = members[0]!; + const checkoutEnvironment = environments.find( + (candidate) => candidate.environmentId === checkout.environmentId, + ); + if (!checkoutEnvironment) { + return unavailable( + "environment-missing", + "This checkout's environment is no longer available.", + ); + } + const sharesEnvironment = group.memberProjects.some( + (member) => + member.environmentId === checkout.environmentId && + member.physicalProjectKey !== checkout.physicalProjectKey, + ); + return { + kind: "checkout", + group, + checkout, + environmentId: checkout.environmentId, + label: `${group.displayName} / ${checkoutEnvironment.label}${sharesEnvironment ? ` · ${checkout.workspaceRoot}` : ""}`, + members, + environmentIds: [checkout.environmentId], + }; + } + return { + kind: "project", + group, + environmentId: environment?.environmentId ?? null, + label: `${group.displayName} / ${environment?.label ?? "All checkouts"}`, + members, + environmentIds: [...new Set(members.map((member) => member.environmentId))], + }; + } + if (environment) { + return { + kind: "environment", + environmentId: environment.environmentId, + label: environment.label, + members: [], + environmentIds: [environment.environmentId], + }; + } + return { + kind: "all", + label: "All environments", + members: [], + environmentIds: environments.map((candidate) => candidate.environmentId), + }; +} diff --git a/apps/web/src/components/settings/useSettingsProjectGroups.ts b/apps/web/src/components/settings/useSettingsProjectGroups.ts new file mode 100644 index 000000000000..8eb25149939e --- /dev/null +++ b/apps/web/src/components/settings/useSettingsProjectGroups.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; + +import { useClientSettings } from "../../hooks/useSettings"; +import { selectProjectGroupingSettings } from "../../logicalProject"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { useProjects } from "../../state/entities"; + +/** Settings uses the same logical projects as the sidebar, sorted by display name. */ +export function useSettingsProjectGroups() { + const projects = useProjects(); + const settings = useClientSettings(selectProjectGroupingSettings); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + return useMemo(() => { + const labels = new Map(environments.map((entry) => [entry.environmentId, entry.label])); + return buildSidebarProjectSnapshots({ + projects, + settings, + primaryEnvironmentId, + resolveEnvironmentLabel: (id) => labels.get(id) ?? null, + }).sort((a, b) => a.displayName.localeCompare(b.displayName)); + }, [environments, primaryEnvironmentId, projects, settings]); +} diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx index fa79f46fbb2c..36b20d1560fe 100644 --- a/apps/web/src/routes/settings.projects.tsx +++ b/apps/web/src/routes/settings.projects.tsx @@ -1,24 +1,21 @@ import { createFileRoute } from "@tanstack/react-router"; import { ProjectsSettings } from "../components/settings/ProjectsSettings"; +import { validateSettingsScopeSearch } from "../components/settings/settingsScope"; export const Route = createFileRoute("/settings/projects")({ - validateSearch: (search: Record) => ({ - project: typeof search.project === "string" ? search.project : undefined, - machine: typeof search.machine === "string" ? search.machine : undefined, - }), + validateSearch: validateSettingsScopeSearch, component: ProjectsRoute, }); function ProjectsRoute() { - const { project, machine } = Route.useSearch(); + const search = Route.useSearch(); const navigate = Route.useNavigate(); return ( { + value={search} + onScopeChange={(scope) => { void navigate({ - search: { project: project ?? undefined, machine: machine ?? undefined }, + search: scope, replace: true, }); }} From e22040dfc190d04573238c5db2e69df72e5f1017 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 13:56:40 -0700 Subject: [PATCH 06/26] feat(settings): edit any scopable setting as a project override (#10639) --- .../features/settings/SettingsRouteScreen.tsx | 52 +- .../settings/autoSettleSettingsSync.test.ts | 78 ++ .../settings/autoSettleSettingsSync.ts | 31 + .../threads/new-task-flow-provider.tsx | 31 +- apps/web/src/components/ChatView.tsx | 64 +- apps/web/src/components/CommandPalette.tsx | 5 +- .../src/components/chat/DraftHeroHeadline.tsx | 13 +- .../chat/ProviderModelPicker.test.tsx | 15 + .../components/chat/ProviderModelPicker.tsx | 10 +- .../components/projectScriptEditor.test.tsx | 245 ++++ .../src/components/projectScriptEditor.tsx | 283 +++-- .../pullRequest/PullRequestDetailPanel.tsx | 27 +- .../settings/ConnectionsSettings.tsx | 5 +- .../settings/DiagnosticsSettings.tsx | 48 +- .../settings/IntegrationsSettings.test.tsx | 12 + .../settings/IntegrationsSettings.tsx | 75 +- .../settings/KeybindingsSettings.tsx | 57 +- .../settings/ProjectActionsSettings.tsx | 226 ++++ .../ProjectDefaultActionsSettings.tsx | 114 -- .../settings/ProjectDefaultsSettings.tsx | 709 +++++------- .../settings/ProjectSettingsPanel.tsx | 1031 +---------------- .../components/settings/ProjectsSettings.tsx | 40 +- .../settings/ProviderInstanceCard.tsx | 4 + ...ProviderSettingsPanel.environment.test.tsx | 33 +- .../settings/ProviderSettingsPanel.tsx | 31 +- .../settings/ResourceTelemetryDiagnostics.tsx | 41 +- .../src/components/settings/ScopedSwitch.tsx | 19 + .../settings/SettingInheritance.test.ts | 57 + .../settings/SettingInheritance.tsx | 303 +++++ .../settings/SettingsBreadcrumb.tsx | 202 +++- .../components/settings/SettingsPanels.tsx | 272 +++-- .../settings/SettingsScopeContext.tsx | 65 ++ .../settings/SettingsScopeNotice.tsx | 90 ++ .../settings/SettingsScopeSelects.tsx | 154 --- .../settings/SettingsSidebarNav.tsx | 17 +- .../settings/SharedSettingsMismatchAlert.tsx | 32 - .../settings/SnapShotSettings.test.tsx | 1 + .../settings/SourceControlSettings.tsx | 54 +- .../SourceControlWritingSettings.test.tsx | 221 ++++ .../settings/SourceControlWritingSettings.tsx | 232 +++- .../settings/scopedSettings.test.ts | 450 +++++++ .../src/components/settings/scopedSettings.ts | 361 ++++++ .../components/settings/settingsLayout.tsx | 190 ++- .../components/settings/settingsScope.test.ts | 27 +- .../src/components/settings/settingsScope.ts | 23 +- ...ogic.test.ts => settingsScopeAxis.test.ts} | 2 +- ...eSelects.logic.ts => settingsScopeAxis.ts} | 0 .../settings/settingsScopeNavigation.test.ts | 247 ++++ .../settings/settingsScopeNavigation.ts | 32 + .../settings/settingsSearch.test.ts | 199 +++- .../src/components/settings/settingsSearch.ts | 201 +++- .../useAvailableSettingsSearchItems.ts | 24 +- .../settings/useProjectScriptSettings.ts | 193 +++ .../settings/useScopedModelAvailability.ts | 55 + .../components/settings/useScopedSettings.ts | 121 ++ apps/web/src/components/ui/button.tsx | 2 + apps/web/src/components/ui/switch.tsx | 14 +- apps/web/src/hooks/useHandleNewThread.test.ts | 125 +- apps/web/src/hooks/useHandleNewThread.ts | 37 +- apps/web/src/hooks/useSettings.ts | 69 -- apps/web/src/lib/resourceTelemetryState.ts | 25 +- apps/web/src/routes/settings.integrations.tsx | 6 +- apps/web/src/routes/settings.projects.tsx | 20 +- apps/web/src/routes/settings.providers.tsx | 24 +- apps/web/src/routes/settings.tsx | 155 ++- apps/web/src/state/server.ts | 9 - docs/internals/overview.md | 9 + docs/user/project-settings.md | 62 +- docs/user/thread-sidebar.md | 10 +- 69 files changed, 5158 insertions(+), 2533 deletions(-) create mode 100644 apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts create mode 100644 apps/mobile/src/features/settings/autoSettleSettingsSync.ts create mode 100644 apps/web/src/components/projectScriptEditor.test.tsx create mode 100644 apps/web/src/components/settings/ProjectActionsSettings.tsx delete mode 100644 apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx create mode 100644 apps/web/src/components/settings/ScopedSwitch.tsx create mode 100644 apps/web/src/components/settings/SettingInheritance.test.ts create mode 100644 apps/web/src/components/settings/SettingInheritance.tsx create mode 100644 apps/web/src/components/settings/SettingsScopeContext.tsx create mode 100644 apps/web/src/components/settings/SettingsScopeNotice.tsx delete mode 100644 apps/web/src/components/settings/SettingsScopeSelects.tsx delete mode 100644 apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx create mode 100644 apps/web/src/components/settings/SourceControlWritingSettings.test.tsx create mode 100644 apps/web/src/components/settings/scopedSettings.test.ts create mode 100644 apps/web/src/components/settings/scopedSettings.ts rename apps/web/src/components/settings/{SettingsScopeSelects.logic.test.ts => settingsScopeAxis.test.ts} (98%) rename apps/web/src/components/settings/{SettingsScopeSelects.logic.ts => settingsScopeAxis.ts} (100%) create mode 100644 apps/web/src/components/settings/settingsScopeNavigation.test.ts create mode 100644 apps/web/src/components/settings/settingsScopeNavigation.ts create mode 100644 apps/web/src/components/settings/useProjectScriptSettings.ts create mode 100644 apps/web/src/components/settings/useScopedModelAvailability.ts create mode 100644 apps/web/src/components/settings/useScopedSettings.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 77036c212517..d57904b7e7a2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -41,14 +41,8 @@ import { DEFAULT_SERVER_SETTINGS, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - type ServerSettingsPatch, } from "@t3tools/contracts"; -import { - filterSharedServerPatch, - findSharedSettingsMismatches, - pickSharedServerSettings, - supportsSharedSettingsSync, -} from "@t3tools/client-runtime/state/shared-settings"; +import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -61,6 +55,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -588,10 +583,9 @@ function GeneralSettingsSection() { const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; /** - * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first eligible sync target provides the - * reference value. Edits fan out to every eligible target, and a mismatch row - * lets the user push the reference out. + * Mobile edits auto-settle defaults across connected, capable environments. + * The first target supplies the displayed values. Applying them leaves each + * environment's other defaults and overrides intact. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -610,24 +604,20 @@ function AutoSettleSettingsRows() { return null; } - const writeToAll = (patch: ServerSettingsPatch) => { + const writeToAll = (patch: Partial) => { for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; - const mismatches = findSharedSettingsMismatches({ - primaryEnvironmentId: reference.environmentId, - primarySettings: referenceSettings, - primaryCapabilities: reference.serverConfig?.environment.capabilities, - environments: environments.map((environment) => ({ + const { patch: autoSettlePatch, mismatches } = planAutoSettleSettingsSync( + { environmentId: reference.environmentId, settings: referenceSettings }, + syncTargets.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, - capabilities: environment.serverConfig?.environment.capabilities, })), - }); + ); const afterDays = referenceSettings.sidebarAutoSettleAfterDays; const commitDays = () => { @@ -681,7 +671,7 @@ function AutoSettleSettingsRows() { {mismatches.length > 0 ? ( - Settings differ + Auto-settle defaults differ {mismatches.map((mismatch) => mismatch.label).join(", ")} @@ -689,30 +679,18 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings( - referenceSettings, - reference.serverConfig?.environment.capabilities, - ); for (const mismatch of mismatches) { - const target = environments.find( - (candidate) => candidate.environmentId === mismatch.environmentId, - ); void updateSettings({ environmentId: mismatch.environmentId, - input: { - patch: filterSharedServerPatch( - patch, - target?.serverConfig?.environment.capabilities, - target?.serverConfig?.settings, - referenceSettings, - ), - }, + input: { patch: autoSettlePatch }, }); } }} className="rounded-full bg-subtle px-4 py-2 active:opacity-70" > - Apply to all + + Apply auto-settle defaults + ) : null} diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts new file mode 100644 index 000000000000..ec550725adcf --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.test.ts @@ -0,0 +1,78 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { planAutoSettleSettingsSync } from "./autoSettleSettingsSync"; + +const reference = { + environmentId: EnvironmentId.make("reference"), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + newWorktreesStartFromOrigin: false, + continueThreadsAfterServerUpdate: false, + }, +}; + +describe("auto-settle settings sync", () => { + it("ignores differences in independently configured environment settings", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Keep this environment's writing instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + + expect(plan.mismatches).toEqual([]); + expect(plan.patch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: true, + }); + }); + + it("applies only auto-settle defaults when another environment differs", () => { + const target = { + environmentId: EnvironmentId.make("remote"), + label: "Remote", + settings: { + ...reference.settings, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: false, + newWorktreesStartFromOrigin: true, + continueThreadsAfterServerUpdate: true, + sourceControlWritingStyle: { + ...reference.settings.sourceControlWritingStyle, + customInstructions: "Preserve these instructions.", + }, + }, + }; + + const plan = planAutoSettleSettingsSync(reference, [target]); + const updated = { ...target.settings, ...plan.patch }; + + expect(plan.mismatches).toEqual([target]); + expect(updated.sidebarAutoSettleAfterDays).toBe(7); + expect(updated.sidebarAutoSettleOnMerge).toBe(true); + expect(updated.newWorktreesStartFromOrigin).toBe(true); + expect(updated.continueThreadsAfterServerUpdate).toBe(true); + expect(updated.sourceControlWritingStyle).toEqual(target.settings.sourceControlWritingStyle); + }); + + it("does not compare the reference or a target without loaded settings", () => { + const plan = planAutoSettleSettingsSync(reference, [ + { ...reference, label: "Reference" }, + { environmentId: EnvironmentId.make("loading"), label: "Loading", settings: null }, + ]); + + expect(plan.mismatches).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/settings/autoSettleSettingsSync.ts b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts new file mode 100644 index 000000000000..6addfa381fde --- /dev/null +++ b/apps/mobile/src/features/settings/autoSettleSettingsSync.ts @@ -0,0 +1,31 @@ +import type { EnvironmentId, ServerSettings } from "@t3tools/contracts"; + +export type AutoSettleSettings = Pick< + ServerSettings, + "sidebarAutoSettleAfterDays" | "sidebarAutoSettleOnMerge" +>; + +interface AutoSettleSyncTarget { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly settings: AutoSettleSettings | null; +} + +/** Receives connected, capable targets. Applying these defaults must preserve other settings. */ +export function planAutoSettleSettingsSync( + reference: { readonly environmentId: EnvironmentId; readonly settings: AutoSettleSettings }, + targets: readonly AutoSettleSyncTarget[], +) { + const patch: AutoSettleSettings = { + sidebarAutoSettleAfterDays: reference.settings.sidebarAutoSettleAfterDays, + sidebarAutoSettleOnMerge: reference.settings.sidebarAutoSettleOnMerge, + }; + const mismatches = targets.filter( + (target) => + target.environmentId !== reference.environmentId && + target.settings !== null && + (target.settings.sidebarAutoSettleAfterDays !== patch.sidebarAutoSettleAfterDays || + target.settings.sidebarAutoSettleOnMerge !== patch.sidebarAutoSettleOnMerge), + ); + return { patch, mismatches }; +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 535581b58e75..d0687e7e7b66 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -13,10 +13,12 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, MessageId, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { isDefaultThreadEnvModeSettled, @@ -428,17 +430,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); + // Environment settings with the project's overrides applied; the + // aggregate's own legacy fields still count until the server folds them. + const projectSettings = useMemo( + () => + resolveProjectSettings( + selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedProject?.id ?? null, + selectedProject, + ), + [selectedEnvironmentServerConfig?.settings, selectedProject], + ); + const projectThreadEnvMode = + projectSettings.sources.defaultThreadEnvMode === "project" + ? projectSettings.settings.defaultThreadEnvMode + : undefined; const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: selectedProject?.defaultThreadEnvMode, + projectSetting: projectThreadEnvMode, projectFile: t3ProjectFileDefaultMode, - globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + globalDefault: projectSettings.settings.defaultThreadEnvMode, }); // While unsettled the resolved default is provisional. Nothing may write // it into the draft during that window (the auto-branch effect does), or // the frozen interim value beats the t3.json default once it loads. const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: selectedProject?.defaultThreadEnvMode, + projectSetting: projectThreadEnvMode, projectFilePending: t3ProjectFileQuery.isPending, }); const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; @@ -449,9 +466,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // value keeps tracking the server setting when the config loads late. const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = - draftStartFromOrigin ?? - selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? - true; + draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; // Antigravity keeps unavailable selections so sign-out or a catalog change @@ -463,9 +478,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const projectDefaultModelSelection = resolveDefaultableModelSelection( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? - selectedEnvironmentServerConfig?.settings.defaultModelSelection ?? - null, + projectSettings.settings.defaultModelSelection, ); const storedStickyModelSelection = useStickyComposerModelSelection(); const stickyModelSelection = resolveDefaultableModelSelection( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51b9c5eabc48..fc529b02a73b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -68,6 +68,7 @@ import { projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -304,7 +305,6 @@ import { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, - primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; @@ -1516,7 +1516,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1802,17 +1801,14 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? - settings.defaultModelSelection ?? - NO_PROVIDER_MODEL_SELECTION, + resolveProjectSettings( + settings, + fallbackDraftProject?.id ?? null, + fallbackDraftProject ?? undefined, + ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [ - draftThread, - fallbackDraftProject?.defaultModelSelection, - settings.defaultModelSelection, - threadId, - ], + [draftThread, fallbackDraftProject, settings, threadId], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -2033,12 +2029,16 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + // Environment settings with the active project's overrides applied. + const activeProjectSettings = useMemo( + () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined), + [activeProject, settings], + ); const activeProjectScripts = useMemo( () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), [activeProject, settings], ); - const activeProjectDefaultModelSelection = - activeProject?.defaultModelSelection ?? settings.defaultModelSelection; + const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -3945,6 +3945,9 @@ export default function ChatView(props: ChatViewProps) { ], ); + const supportsProjectSettingsOverrides = + environmentById.get(environmentId)?.serverConfig?.environment.capabilities + .projectSettingsOverrides === true; const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -3958,11 +3961,22 @@ export default function ChatView(props: ChatViewProps) { await updateProjectScriptSettings({ environmentId, input: { - patch: { - projectScriptOverrides: { - [input.projectId]: input.nextScripts, - }, - }, + // The canonical key on servers that understand it; the legacy + // per-project map is still translated on older ones. + patch: supportsProjectSettingsOverrides + ? { + projectSettingsOverrides: { + [input.projectId]: { + ...settings.projectSettingsOverrides[input.projectId], + defaultProjectScripts: input.nextScripts, + }, + }, + } + : { + projectScriptOverrides: { + [input.projectId]: input.nextScripts, + }, + }, }, }), () => undefined, @@ -3987,7 +4001,13 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProjectScriptSettings, upsertKeybinding], + [ + environmentId, + settings.projectSettingsOverrides, + supportsProjectSettingsOverrides, + updateProjectScriptSettings, + upsertKeybinding, + ], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -5396,7 +5416,7 @@ export default function ChatView(props: ChatViewProps) { ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? - primaryServerSettings.newWorktreesStartFromOrigin) + activeProjectSettings.settings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, @@ -8045,7 +8065,7 @@ export default function ChatView(props: ChatViewProps) { envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, - newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); @@ -8057,7 +8077,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, - primaryServerSettings.newWorktreesStartFromOrigin, + activeProjectSettings.settings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 4a9877f87a02..3533678ecb2d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1814,8 +1814,7 @@ function OpenCommandPaletteDialog(props: { }, }); - // There is no projects listing page; the action targets the contextual - // project (active thread/draft, falling back to the first sidebar group). + // Target the active thread or draft's project, falling back to the first sidebar group. const contextualProjectGroup = (contextualProjectRef ? projectGroupByTargetKey.get( @@ -1867,8 +1866,6 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, - search: (previous) => - item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4a9421f2011f..1e0d295c098f 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -27,6 +27,7 @@ import { MenuTrigger, } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; interface DraftHeroHeadlineProps { readonly draftId: DraftId | null; @@ -150,11 +151,13 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const defaultModelSelection = - project.defaultModelSelection ?? - environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings.defaultModelSelection; + const environmentSettings = environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings; + const defaultModelSelection = environmentSettings + ? resolveProjectSettings(environmentSettings, project.id, project).settings + .defaultModelSelection + : project.defaultModelSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/components/chat/ProviderModelPicker.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.test.tsx index b1bb8ba9c74a..41e86f841ee4 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.test.tsx @@ -34,6 +34,7 @@ function renderPicker(input: { model: string; options: ReadonlyArray; includeEntry?: boolean; + triggerLabel?: string; }) { const instanceId = ProviderInstanceId.make(input.instanceId); const entry = providerEntry(input.instanceId, input.driver); @@ -45,11 +46,25 @@ function renderPicker(input: { instanceEntries={input.includeEntry === false ? [] : [entry]} modelOptionsByInstance={new Map([[instanceId, input.options]])} onInstanceModelChange={() => {}} + {...(input.triggerLabel ? { triggerLabel: input.triggerLabel } : {})} />, ); } describe("ProviderModelPicker", () => { + it("shows a neutral aggregate value without a representative model or availability badge", () => { + const markup = renderPicker({ + instanceId: "codex_personal", + driver: "codex", + model: "gpt-5", + options: [{ slug: "gpt-5", name: "GPT 5", isUnavailable: true }], + triggerLabel: "Mixed values", + }); + expect(markup).toContain("Mixed values"); + expect(markup).not.toContain("GPT 5"); + expect(markup).not.toContain("Unavailable"); + }); + it.each(["", ANTIGRAVITY_DEFAULT_MODEL])( "shows a choice prompt before Antigravity has an account catalog for %s", (model) => { diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index fb2cdb2f1280..6f777399451d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -48,6 +48,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { open?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; + /** Aggregate settings can show a neutral value without claiming one provider is selected. */ + triggerLabel?: string; triggerAriaLabel?: string; onOpenChange?: (open: boolean) => void; onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void; @@ -181,7 +183,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { - {activeEntry ? ( + {activeEntry && props.triggerLabel === undefined ? ( } > - {triggerTitle} + {props.triggerLabel ?? triggerTitle} - {triggerLabel} + {props.triggerLabel ?? triggerLabel} - {selectedModel?.isUnavailable ? ( + {selectedModel?.isUnavailable && props.triggerLabel === undefined ? ( Unavailable diff --git a/apps/web/src/components/projectScriptEditor.test.tsx b/apps/web/src/components/projectScriptEditor.test.tsx new file mode 100644 index 000000000000..becb3a369f62 --- /dev/null +++ b/apps/web/src/components/projectScriptEditor.test.tsx @@ -0,0 +1,245 @@ +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("./ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? children : null), + DialogDescription: "p", + DialogFooter: "footer", + DialogHeader: "header", + DialogPanel: "section", + DialogPopup: "section", + DialogTitle: "h2", +})); +vi.mock("./ui/alert-dialog", () => ({ + AlertDialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ? children : null, + AlertDialogClose: "button", + AlertDialogDescription: "p", + AlertDialogFooter: "footer", + AlertDialogHeader: "header", + AlertDialogPopup: "section", + AlertDialogTitle: "h2", +})); +vi.mock("./ui/button", () => ({ Button: "button" })); +vi.mock("./ui/input", () => ({ Input: "input" })); +vi.mock("./ui/label", () => ({ Label: "label" })); +vi.mock("./ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => children, + PopoverPopup: () => null, + PopoverTrigger: "button", +})); +vi.mock("./ui/switch", () => ({ Switch: "input" })); +vi.mock("./ui/textarea", () => ({ Textarea: "textarea" })); + +import { + EMPTY_PROJECT_SCRIPT_INPUT, + ProjectScriptEditorDialog, + type ProjectScriptActionResult, + type ProjectScriptEditorRequest, +} from "./projectScriptEditor"; + +const onSubmit = vi.fn[0]["onSubmit"]>(); +const onClose = vi.fn(); +const onDelete = vi.fn(); +let renderer: ReactTestRenderer | null; + +function request(name: string, error?: string): ProjectScriptEditorRequest { + return { + scriptId: name, + initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name, command: `run-${name}` }, + ...(error === undefined ? {} : { error }), + }; +} + +function editor(nextRequest: ProjectScriptEditorRequest) { + return ( + + + + ); +} + +function open(nextRequest: ProjectScriptEditorRequest) { + act(() => { + if (renderer) renderer.update(editor(nextRequest)); + else renderer = create(editor(nextRequest)); + }); +} + +function submit(): Promise { + return renderer!.root.findByType("form").props.onSubmit({ preventDefault() {} }); +} + +function saveButton() { + return renderer!.root.findAllByType("button").find((button) => button.props.type === "submit")!; +} + +function deferredSave() { + let resolve!: (result: ProjectScriptActionResult) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolveResult, rejectResult) => { + resolve = resolveResult; + reject = rejectResult; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + renderer = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + onSubmit.mockReset(); + onClose.mockReset(); + onDelete.mockReset(); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +describe("project action editor save lifecycle", () => { + it("blocks repeated submits and edits until the current save completes", async () => { + const save = deferredSave(); + onSubmit.mockReturnValue(save.promise); + open(request("build")); + + let completion!: Promise; + act(() => { + completion = submit(); + void submit(); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(saveButton().props.disabled).toBe(true); + expect(renderer!.root.findByType("fieldset").props.disabled).toBe(true); + const cancel = renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Cancel"))!; + expect(cancel.props.disabled).not.toBe(true); + + await act(async () => { + save.resolve(AsyncResult.success(undefined)); + await completion; + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close a replacement request or release its in-flight save", async () => { + const first = deferredSave(); + const second = deferredSave(); + onSubmit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + open(request("build")); + let firstCompletion!: Promise; + act(() => { + firstCompletion = submit(); + }); + + open(request("test")); + expect(saveButton().props.disabled).toBe(false); + expect(renderer!.root.findByProps({ id: "script-name" }).props.value).toBe("test"); + let secondCompletion!: Promise; + act(() => { + secondCompletion = submit(); + }); + + await act(async () => { + first.resolve(AsyncResult.success(undefined)); + await firstCompletion; + }); + expect(onClose).not.toHaveBeenCalled(); + expect(saveButton().props.disabled).toBe(true); + + await act(async () => { + second.resolve(AsyncResult.success(undefined)); + await secondCompletion; + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]); + }); + + it.each(["failure", "rejection"] as const)( + "ignores a stale %s after the request changes", + async (outcome) => { + const save = deferredSave(); + onSubmit.mockReturnValue(save.promise); + open(request("build")); + let completion!: Promise; + act(() => { + completion = submit(); + }); + + open(request("test", "New request error")); + await act(async () => { + if (outcome === "failure") + save.resolve(AsyncResult.failure(Cause.fail(new Error("Old save error")))); + else save.reject(new Error("Old save error")); + await completion; + }); + + const messages = renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children); + expect(messages).toContain("New request error"); + expect(messages).not.toContain("Old save error"); + expect(saveButton().props.disabled).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + }, + ); + + it("shows a current save error and allows retry", async () => { + onSubmit.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); + onSubmit.mockResolvedValueOnce(AsyncResult.success(undefined)); + open(request("build")); + + await act(async () => { + await submit(); + }); + expect(renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children)).toContain( + "Save failed", + ); + expect(saveButton().props.disabled).toBe(false); + expect(renderer!.root.findByType("fieldset").props.disabled).toBe(false); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + await submit(); + }); + expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it.each(["cancel", "unmount"] as const)("ignores save completion after %s", async (exit) => { + const save = deferredSave(); + onSubmit.mockReturnValue(save.promise); + open(request("build")); + let completion!: Promise; + act(() => { + completion = submit(); + }); + + act(() => { + if (exit === "cancel") { + renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Cancel"))! + .props.onClick(); + } else { + renderer!.unmount(); + renderer = null; + } + }); + onClose.mockClear(); + await act(async () => { + save.resolve(AsyncResult.success(undefined)); + await completion; + }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4ffd453955e9..74b189a02fc5 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -16,7 +16,14 @@ import { PlayIcon, WrenchIcon, } from "lucide-react"; -import React, { type FormEvent, type KeyboardEvent, useEffect, useState } from "react"; +import React, { + type FormEvent, + type KeyboardEvent, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { keybindingValueForCommand, @@ -156,9 +163,22 @@ export function ProjectScriptEditorDialog({ const [autoOpenPreview, setAutoOpenPreview] = useState(false); const [validationError, setValidationError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [savingRequest, setSavingRequest] = useState(null); + const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null); const isOpen = request !== null; const isEditing = request?.scriptId != null; + const isSaving = request !== null && savingRequest === request; + + // A save completion must not affect a replacement request or an unmounted editor. + useLayoutEffect( + () => () => { + if (pendingSubmissionRef.current?.request === request) { + pendingSubmissionRef.current = null; + } + }, + [request], + ); // Hydrate the form whenever a new request opens the dialog. useEffect(() => { @@ -172,8 +192,16 @@ export function ProjectScriptEditorDialog({ setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); setValidationError(request.error ?? null); + setSavingRequest(null); }, [request]); + const close = () => { + pendingSubmissionRef.current = null; + setSavingRequest(null); + setIconPickerOpen(false); + onClose(); + }; + const captureKeybinding = (event: KeyboardEvent) => { if (event.key === "Tab") return; event.preventDefault(); @@ -188,7 +216,7 @@ export function ProjectScriptEditorDialog({ const submit = async (event: FormEvent) => { event.preventDefault(); - if (!request) return; + if (!request || pendingSubmissionRef.current !== null) return; const trimmedName = name.trim(); const trimmedCommand = command.trim(); if (trimmedName.length === 0) { @@ -228,16 +256,31 @@ export function ProjectScriptEditorDialog({ return; } - const result = await onSubmit(request.scriptId, payload); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + const submission = { request }; + pendingSubmissionRef.current = submission; + setSavingRequest(request); + setIconPickerOpen(false); + try { + const result = await onSubmit(request.scriptId, payload); + if (pendingSubmissionRef.current === submission) { + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + } + } else { + close(); + } + } + } catch (error) { + if (pendingSubmissionRef.current === submission) { setValidationError(error instanceof Error ? error.message : "Failed to save action."); } - return; } - setIconPickerOpen(false); - onClose(); + if (pendingSubmissionRef.current === submission) { + pendingSubmissionRef.current = null; + setSavingRequest(null); + } }; return ( @@ -246,8 +289,7 @@ export function ProjectScriptEditorDialog({ open={isOpen} onOpenChange={(open) => { if (!open) { - setIconPickerOpen(false); - onClose(); + close(); } }} > @@ -259,112 +301,115 @@ export function ProjectScriptEditorDialog({ -
-
- -
- - - } - > - - - -
- {SCRIPT_ICONS.map((entry) => { - const isSelected = entry.id === icon; - return ( - - ); - })} -
-
-
+ +
+
+ +
+ + + } + > + + + +
+ {SCRIPT_ICONS.map((entry) => { + const isSelected = entry.id === icon; + return ( + + ); + })} +
+
+
+ setName(event.target.value)} + /> +
+
+
+ setName(event.target.value)} + id="script-keybinding" + placeholder="Press shortcut" + value={keybinding} + readOnly + onKeyDown={captureKeybinding} /> +

+ Press a shortcut. Use Backspace to clear. Shortcuts are + environment-wide. Projects using the same action share its shortcut. +

-
-
- - -

- Press a shortcut. Use Backspace to clear. -

-
-
- -