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({
-