- {detailQuery.isPending ? "Loading pull request details…" : originalUrl}
-
- ) : (
-
-
- {detail.repository}
- #{detail.number}
- ·
- {state === null ? null : (
-
-
- {state.label}
-
- )}
-
-
- {detail.title}
+ {detail !== null || detailQuery.error !== null ? (
+
+ {detail === null ? (
+
+ {originalUrl}
-
-
-
{authorLabel}
-
·
-
opened {formatRelativeTimeLabel(detail.createdAt)}
+ ) : (
+
+
+ {detail.repository}
+ #{detail.number}
+ ·
+ {state === null ? null : (
+
+
+ {state.label}
+
+ )}
+
+
+ {detail.title}
+
+
+
+
{authorLabel}
+
·
+
opened {formatRelativeTimeLabel(detail.createdAt)}
+
-
- )}
-
+ )}
+
+ ) : null}
);
}
diff --git a/knip.jsonc b/knip.jsonc
index 0eae85a29acb..a06e0885a2f8 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -23,6 +23,7 @@
"src/claude-history-worker.ts!",
"scripts/cli.ts",
"scripts/evaluate-thread-titles.ts",
+ "scripts/measure-pr-preview.ts",
"src/provider/testFixtures/*.mjs",
],
// Keep the transitive Effect runtime pinned for standalone npm installs.
diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts
index 64ccb9cdad89..af625bbad56e 100644
--- a/packages/client-runtime/src/state/pullRequestRouting.ts
+++ b/packages/client-runtime/src/state/pullRequestRouting.ts
@@ -29,6 +29,7 @@ const reads = new Set([
WS_METHODS.pullRequestsSummary,
WS_METHODS.pullRequestsStack,
WS_METHODS.pullRequestsDetail,
+ WS_METHODS.pullRequestsPreview,
WS_METHODS.pullRequestsActivity,
WS_METHODS.pullRequestsThreadComments,
WS_METHODS.pullRequestsDiffFileContents,
diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts
index 6fa1141669af..75e6b38f4a2e 100644
--- a/packages/client-runtime/src/state/pullRequests.test.ts
+++ b/packages/client-runtime/src/state/pullRequests.test.ts
@@ -887,6 +887,84 @@ it.effect("keeps concurrent diff file reads on different hosts separate", () =>
),
);
+it.effect("keeps hover previews fresh after edits and turns", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const refreshEvents = yield* PubSub.unbounded();
+ let title = "Original title";
+ let reads = 0;
+ const read = (input: unknown) =>
+ Effect.sync(() => {
+ expect(input).toEqual(reference);
+ reads++;
+ return { ...reference, title };
+ });
+ const reference = {
+ projectId: ProjectId.make("project-1"),
+ repository: "acme/web",
+ number: 1,
+ host: "github.example.com",
+ };
+ const client = {
+ [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents),
+ [WS_METHODS.pullRequestsPreview]: read,
+ [WS_METHODS.pullRequestsDetail]: read,
+ [WS_METHODS.pullRequestsUpdate]: (input: { title: string }) =>
+ Effect.sync(() => {
+ title = input.title;
+ }),
+ [WS_METHODS.pullRequestsRunAction]: () =>
+ Effect.sync(() => {
+ title = "Closed";
+ }),
+ [WS_METHODS.pullRequestsInvalidate]: () => Effect.void,
+ } as unknown as WsRpcProtocolClient;
+ const { atoms, registry } = yield* makeTestRuntime(client);
+ const target = { environmentId: TARGET.environmentId, input: reference };
+ const preview = atoms.preview(target);
+ const unmount = registry.mount(preview);
+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
+ expect(
+ (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
+ ).toBe(title);
+ yield* Effect.promise(() => executeAtomQuery(registry, preview));
+ expect(reads).toBe(1);
+ const edited = yield* Effect.promise(() =>
+ atoms.update.run(registry, { ...target, input: { ...reference, title: "Edited" } }),
+ );
+ expect(AsyncResult.isSuccess(edited)).toBe(true);
+ expect(
+ (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
+ ).toBe("Edited");
+ yield* Effect.promise(() =>
+ atoms.runAction.run(registry, { ...target, input: { ...reference, action: "close" } }),
+ );
+ expect(
+ (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
+ ).toBe("Closed");
+ title = "Refreshed";
+ yield* Effect.promise(() =>
+ atoms.invalidate.run(registry, {
+ environmentId: target.environmentId,
+ input: { reference },
+ }),
+ );
+ expect(
+ (yield* AtomRegistry.getResult(registry, preview, { suspendOnWaiting: true })).title,
+ ).toBe("Refreshed");
+ const refreshed = Latch.makeUnsafe();
+ const stop = registry.subscribe(preview, (result) => {
+ if (AsyncResult.isSuccess(result) && result.value.title === "After turn")
+ refreshed.openUnsafe();
+ });
+ yield* Effect.addFinalizer(() => Effect.sync(stop));
+ title = "After turn";
+ yield* PubSub.publish(refreshEvents, 1);
+ yield* refreshed.await;
+ }),
+ ),
+);
+
it.effect("shares close, reopen, and merge with an untouched client's mounted PR readers", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts
index d8c000863ab1..34768ed3c4b5 100644
--- a/packages/client-runtime/src/state/pullRequests.ts
+++ b/packages/client-runtime/src/state/pullRequests.ts
@@ -176,6 +176,15 @@ export function createPullRequestEnvironmentAtoms(
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
}),
);
+ const preview = writableQueryFamily(
+ createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "environment-data:pull-requests:preview",
+ tag: WS_METHODS.pullRequestsPreview,
+ execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input),
+ staleTimeMs: 60_000,
+ refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
+ }),
+ );
const labelCandidates = writableQueryFamily(
createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:label-candidates",
@@ -221,6 +230,7 @@ export function createPullRequestEnvironmentAtoms(
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
}),
detail,
+ preview,
activity,
threadComments: createEnvironmentRpcCommand(runtime, {
label: "environment-data:pull-requests:thread-comments",
@@ -295,6 +305,7 @@ export function createPullRequestEnvironmentAtoms(
label: "environment-data:pull-requests:run-action",
tag: WS_METHODS.pullRequestsRunAction,
execute: (input) => routedRequest(WS_METHODS.pullRequestsRunAction, input),
+ onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))),
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
@@ -302,6 +313,7 @@ export function createPullRequestEnvironmentAtoms(
label: "environment-data:pull-requests:update",
tag: WS_METHODS.pullRequestsUpdate,
execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),
+ onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))),
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
@@ -485,6 +497,12 @@ export function createPullRequestEnvironmentAtoms(
label: "environment-data:pull-requests:invalidate",
tag: WS_METHODS.pullRequestsInvalidate,
execute: (input) => routedRequest(WS_METHODS.pullRequestsInvalidate, input),
+ onSuccess: ({ environmentId, input }, registry) =>
+ Effect.sync(() => {
+ if (input.reference !== undefined) {
+ registry.refresh(preview({ environmentId, input: input.reference }));
+ }
+ }),
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),
diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts
index 41c9250f2c63..b146810a8aec 100644
--- a/packages/contracts/src/pullRequest.ts
+++ b/packages/contracts/src/pullRequest.ts
@@ -703,6 +703,20 @@ export const PullRequestLinkedThreadsResult = Schema.Struct({
});
export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type;
+/** The complete hover card, without checks, permissions, or branch comparisons. */
+export const PullRequestPreview = Schema.Struct({
+ projectId: ProjectId,
+ repository: TrimmedNonEmptyString,
+ number: PositiveInt,
+ title: TrimmedNonEmptyString,
+ url: TrimmedNonEmptyString,
+ author: Schema.NullOr(PullRequestActor),
+ state: PullRequestState,
+ isDraft: Schema.Boolean,
+ createdAt: IsoDateTime,
+});
+export type PullRequestPreview = typeof PullRequestPreview.Type;
+
/**
* The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
* status check never loads permissions, repository settings, checks, or base comparison data.
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index dec42aac1530..41af6d9f1e9e 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -111,6 +111,7 @@ import {
PullRequestCommentInput,
PullRequestCommentUpdateInput,
PullRequestDetail,
+ PullRequestPreview,
PullRequestDiffFileContentsInput,
PullRequestDiffFileContentsResult,
PullRequestFilesViewedResult,
@@ -395,6 +396,7 @@ export const WS_METHODS = {
pullRequestsStack: "pullRequests.stack",
pullRequestsLinkedThreads: "pullRequests.linkedThreads",
pullRequestsDetail: "pullRequests.detail",
+ pullRequestsPreview: "pullRequests.preview",
pullRequestsActivity: "pullRequests.activity",
pullRequestsThreadComments: "pullRequests.threadComments",
pullRequestsDiffFileContents: "pullRequests.diffFileContents",
@@ -737,6 +739,12 @@ const WsPullRequestsDetailRpc = Rpc.make(WS_METHODS.pullRequestsDetail, {
error: PullRequestRpcError,
});
+const WsPullRequestsPreviewRpc = Rpc.make(WS_METHODS.pullRequestsPreview, {
+ payload: PullRequestRef,
+ success: PullRequestPreview,
+ error: PullRequestRpcError,
+});
+
const WsPullRequestsActivityRpc = Rpc.make(WS_METHODS.pullRequestsActivity, {
payload: PullRequestRef,
success: PullRequestActivity,
@@ -1418,6 +1426,7 @@ export const WsRpcGroup = RpcGroup.make(
WsPullRequestsStackRpc,
WsPullRequestsLinkedThreadsRpc,
WsPullRequestsDetailRpc,
+ WsPullRequestsPreviewRpc,
WsPullRequestsActivityRpc,
WsPullRequestsThreadCommentsRpc,
WsPullRequestsDiffFileContentsRpc,
From 869347bc26051bba7c3c0274a7dfcae7770d3e3a Mon Sep 17 00:00:00 2001
From: Alex Southwell
Date: Sat, 19 Sep 2026 04:42:28 +1000
Subject: [PATCH 015/111] fix(server): retry transient git failures during
checkpoint capture (#11665)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>
---
apps/server/src/vcs/GitVcsDriver.test.ts | 142 ++++++++++++++++++-
apps/server/src/vcs/GitVcsDriver.ts | 2 +-
apps/server/src/vcs/VcsProcess.test.ts | 169 +++++++++++++++++++++++
apps/server/src/vcs/VcsProcess.ts | 35 ++++-
packages/contracts/src/vcs.ts | 4 +
5 files changed, 348 insertions(+), 4 deletions(-)
diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts
index b37119f808ba..4df6d0eefc5c 100644
--- a/apps/server/src/vcs/GitVcsDriver.test.ts
+++ b/apps/server/src/vcs/GitVcsDriver.test.ts
@@ -1,5 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Cause from "effect/Cause";
+import * as Clock from "effect/Clock";
+import * as Duration from "effect/Duration";
import * as Exit from "effect/Exit";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
@@ -14,7 +16,11 @@ import { assert, it } from "@effect/vitest";
import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts";
import * as ServerConfig from "../config.ts";
+import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
+import * as ProcessRunner from "../processRunner.ts";
import * as GitVcsDriver from "./GitVcsDriver.ts";
+import type * as VcsDriver from "./VcsDriver.ts";
+import * as VcsDriverRegistry from "./VcsDriverRegistry.ts";
import * as VcsProcess from "./VcsProcess.ts";
import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
@@ -26,6 +32,10 @@ const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.laye
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(NodeServices.layer),
);
+const GitCaptureContractLayer = Layer.merge(
+ GitContractLayer,
+ ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)),
+);
const runGit = (cwd: string, args: ReadonlyArray) =>
Effect.gen(function* () {
@@ -38,6 +48,22 @@ const runGit = (cwd: string, args: ReadonlyArray) =>
});
});
+const makeCaptureStore = Effect.fn("test.makeCaptureStore")(function* (
+ driver: VcsDriver.VcsDriver["Service"],
+ cwd: string,
+) {
+ const repository = yield* driver.detectRepository(cwd);
+ if (repository === null) return yield* Effect.die("Expected a test Git repository");
+ const handle = { kind: repository.kind, repository, driver };
+ return yield* CheckpointStore.make.pipe(
+ Effect.provideService(VcsDriverRegistry.VcsDriverRegistry, {
+ get: () => Effect.succeed(driver),
+ detect: () => Effect.succeed(handle),
+ resolve: () => Effect.succeed(handle),
+ }),
+ );
+});
+
type GitContractError = GitCommandError | PlatformError.PlatformError;
runVcsDriverContractSuite({
@@ -260,6 +286,112 @@ it.effect("checkpoint recovery refuses excessive candidates before probing", ()
}).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
);
+it.effect.each([
+ { phase: "add", nestedRecovery: false, expireRecovery: false },
+ { phase: "update-ref", nestedRecovery: false, expireRecovery: false },
+ { phase: "update-ref", nestedRecovery: true, expireRecovery: false },
+ { phase: "add", nestedRecovery: true, expireRecovery: false },
+ { phase: "add", nestedRecovery: true, expireRecovery: true },
+])(
+ "checkpoint handles a $phase lock with nestedRecovery=$nestedRecovery, expireRecovery=$expireRecovery",
+ ({ phase, nestedRecovery, expireRecovery }) =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const liveRunner = yield* ProcessRunner.ProcessRunner;
+ const driver = yield* GitVcsDriver.makeVcsDriverShape();
+ const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-ref-race-" });
+ const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
+ if (nestedRecovery) yield* git(["init", "empty"]);
+ const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index"));
+ const refLockPath = path.join(cwd, ".git", `${checkpointRef}.lock`);
+ const failed = yield* Deferred.make();
+ const retryReached = yield* Deferred.make();
+ const allowRetry = yield* Deferred.make();
+ const clock = yield* Clock.Clock;
+ const privateIndexes = new Set();
+ let racedAttempts = 0;
+ let discoveries = 0;
+ let stageError: VcsProcessExitError | undefined;
+ const captureProcess = yield* VcsProcess.make.pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, {
+ run: (input) => {
+ if (input.args.includes("--others")) discoveries += 1;
+ if (input.env?.GIT_INDEX_FILE) privateIndexes.add(input.env.GIT_INDEX_FILE);
+ const initialStage =
+ phase === "add" &&
+ nestedRecovery &&
+ !input.args.some((arg) => arg.startsWith(":(exclude,literal)"));
+ if (!input.args.includes(phase) || initialStage || ++racedAttempts !== 1) {
+ return liveRunner.run(input);
+ }
+ const lockPath = phase === "add" ? `${input.env!.GIT_INDEX_FILE!}.lock` : refLockPath;
+ return Effect.gen(function* () {
+ yield* fs
+ .makeDirectory(path.dirname(lockPath), { recursive: true })
+ .pipe(Effect.orDie);
+ yield* fs.writeFileString(lockPath, "concurrent ref writer").pipe(Effect.orDie);
+ return yield* liveRunner.run(input).pipe(
+ Effect.ensuring(fs.remove(lockPath).pipe(Effect.orDie)),
+ Effect.tap(() => Deferred.succeed(failed, undefined)),
+ );
+ });
+ },
+ }),
+ );
+ const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
+ Effect.provideService(VcsProcess.VcsProcess, {
+ run: (input) =>
+ captureProcess.run(input).pipe(
+ Effect.tapError((error) => {
+ if (input.args.includes("add") && error._tag === "VcsProcessExitError")
+ stageError = error;
+ return Effect.void;
+ }),
+ ),
+ }),
+ );
+ const captureStore = yield* makeCaptureStore(captureDriver, cwd);
+ const fiber = yield* captureStore.captureCheckpoint({ cwd, checkpointRef }).pipe(
+ Effect.provideService(Clock.Clock, {
+ ...clock,
+ sleep: (duration) =>
+ Duration.toMillis(duration) === 75
+ ? Deferred.succeed(retryReached, undefined).pipe(
+ Effect.andThen(Deferred.await(allowRetry)),
+ )
+ : clock.sleep(duration),
+ }),
+ Effect.exit,
+ Effect.forkScoped,
+ );
+ yield* Deferred.await(failed);
+ yield* Deferred.await(retryReached);
+ if (expireRecovery) yield* TestClock.adjust("5 seconds");
+ else yield* Deferred.succeed(allowRetry, undefined);
+ const result = yield* Fiber.join(fiber);
+ if (expireRecovery) {
+ if (Exit.isSuccess(result))
+ return yield* Effect.die("Expected the recovery deadline to expire");
+ const error = Cause.findErrorOption(result.cause);
+ assert.isTrue(error._tag === "Some");
+ if (error._tag === "Some") assert.strictEqual(error.value, stageError);
+ } else assert.isTrue(Exit.isSuccess(result));
+ assert.strictEqual(racedAttempts, expireRecovery ? 1 : 2);
+ assert.strictEqual(discoveries, nestedRecovery ? 1 : 0);
+ assert.strictEqual(privateIndexes.size, 1);
+ assert.strictEqual(
+ yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef }),
+ !expireRecovery,
+ );
+ for (const index of privateIndexes) {
+ assert.isFalse(yield* fs.exists(index));
+ assert.isFalse(yield* fs.exists(`${index}.lock`));
+ }
+ assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
+ }).pipe(Effect.scoped, Effect.provide(GitCaptureContractLayer)),
+);
+
for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
it.effect(`checkpoint recovery has one deadline including ${blockedPhase}`, () =>
Effect.gen(function* () {
@@ -276,11 +408,15 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
let stageError: VcsProcessExitError | undefined;
let privateIndex: string | undefined;
let interrupted = false;
+ let stagingAttempts = 0;
const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
Effect.provideService(VcsProcess.VcsProcess, {
run: (input) => {
const staging = input.args.includes("add") && input.args.includes("-A");
- if (staging) privateIndex = input.env?.GIT_INDEX_FILE;
+ if (staging) {
+ privateIndex = input.env?.GIT_INDEX_FILE;
+ stagingAttempts += 1;
+ }
const block =
(blockedPhase === "discovery" && input.args.includes("--others")) ||
(blockedPhase === "probe" && input.cwd !== cwd && input.args.includes("rev-parse")) ||
@@ -322,7 +458,8 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
},
}),
);
- const fiber = yield* captureDriver.checkpoints
+ const captureStore = yield* makeCaptureStore(captureDriver, cwd);
+ const fiber = yield* captureStore
.captureCheckpoint({ cwd, checkpointRef })
.pipe(Effect.flip, Effect.forkScoped);
if (blockedPhase === "probe") {
@@ -333,6 +470,7 @@ for (const blockedPhase of ["discovery", "probe", "retry"] as const) {
yield* TestClock.adjust(blockedPhase === "probe" ? "2 seconds" : "5 seconds");
const error = yield* Fiber.join(fiber);
assert.strictEqual(error, stageError);
+ assert.strictEqual(stagingAttempts, blockedPhase === "retry" ? 2 : 1);
assert.isTrue(interrupted);
assert.isDefined(privateIndex);
assert.isFalse(yield* fs.exists(privateIndex!));
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
index a74a88c4b09a..2aaabeed3d7a 100644
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -766,7 +766,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
const checkpoints: VcsDriver.VcsCheckpointOps = {
captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) {
- const operation = "GitVcsDriver.checkpoints.captureCheckpoint";
+ const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION;
const indexConfig = [
"-c",
"core.fsmonitor=false",
diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts
index 80dea03b245c..169383d2cbf8 100644
--- a/apps/server/src/vcs/VcsProcess.test.ts
+++ b/apps/server/src/vcs/VcsProcess.test.ts
@@ -4,10 +4,13 @@ import { HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
import * as Duration from "effect/Duration";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Cause from "effect/Cause";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
+import * as Schema from "effect/Schema";
import { TestClock } from "effect/testing";
import { ChildProcessSpawner } from "effect/unstable/process";
@@ -19,6 +22,8 @@ import {
import * as ProcessRunner from "../processRunner.ts";
import * as VcsProcess from "./VcsProcess.ts";
+const encodeExitError = Schema.encodeEffect(Schema.fromJsonString(VcsProcessExitError));
+
const run = (input: VcsProcess.VcsProcessInput) =>
Effect.gen(function* () {
const process = yield* VcsProcess.VcsProcess;
@@ -50,6 +55,170 @@ const captureProcessResult = (
);
describe("VcsProcess.run", () => {
+ it.effect.each([
+ { stderr: "fatal: Unable to create '/private/repo/index.lock': File exists", retryable: true },
+ {
+ stderr: "fatal: Unable to create '/private/repo/index.lock': Permission denied",
+ retryable: false,
+ },
+ { stderr: 'error: open("/private/repo/file"): No such file or directory', retryable: true },
+ {
+ stderr: "fatal: unable to stat '/private/repo/file': No such file or directory",
+ retryable: true,
+ },
+ { stderr: 'error: open("/private/repo/file"): Permission denied', retryable: false },
+ {
+ stderr: "error: '/private/repo/child/' does not have a commit checked out",
+ retryable: false,
+ },
+ { stderr: "fatal: index file corrupt", retryable: false },
+ ])(
+ "classifies Git exit retryability without retaining stderr: $stderr",
+ ({ stderr, retryable }) =>
+ Effect.gen(function* () {
+ const error = yield* captureProcessResult(
+ Effect.succeed({
+ stdout: "",
+ stderr,
+ code: ChildProcessSpawner.ExitCode(128),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false,
+ stdoutInvalidUtf8: false,
+ stderrInvalidUtf8: false,
+ }),
+ );
+ assert.instanceOf(error, VcsProcessExitError);
+ expect(error.retryable === true).toBe(retryable);
+ const encoded = yield* encodeExitError(error);
+ expect(encoded).not.toContain("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/private/repo");
+ }),
+ );
+
+ it.effect.each(["timeout", "spawn"] as const)(
+ "checkpoint commands do not retry %s failures",
+ (kind) =>
+ Effect.gen(function* () {
+ let attempts = 0;
+ const fields = { command: "git", argumentCount: 2, cwd: "/workspace" };
+ const failure =
+ kind === "timeout"
+ ? new ProcessRunner.ProcessTimeoutError({ ...fields, timeoutMs: 30_000 })
+ : new ProcessRunner.ProcessSpawnError({ ...fields, cause: new Error("spawn failed") });
+ const service = yield* VcsProcess.make.pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, {
+ run: () =>
+ Effect.sync(() => {
+ attempts += 1;
+ }).pipe(Effect.andThen(Effect.fail(failure))),
+ }),
+ );
+ const error = yield* service
+ .run({
+ ...baseInput,
+ operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
+ })
+ .pipe(Effect.flip);
+ expect(attempts).toBe(1);
+ expect(error._tag).toBe(
+ kind === "timeout" ? "VcsProcessTimeoutError" : "VcsProcessSpawnError",
+ );
+ }),
+ );
+
+ it.effect.each([
+ {
+ name: "recovers after two lock failures",
+ failures: 2,
+ attempts: 3,
+ transient: true,
+ capture: true,
+ streaming: false,
+ },
+ {
+ name: "bounds persistent lock failures",
+ failures: Infinity,
+ attempts: 3,
+ transient: true,
+ capture: true,
+ streaming: false,
+ },
+ {
+ name: "does not retry unknown exits",
+ failures: Infinity,
+ attempts: 1,
+ transient: false,
+ capture: true,
+ streaming: false,
+ },
+ {
+ name: "leaves other operations unchanged",
+ failures: Infinity,
+ attempts: 1,
+ transient: true,
+ capture: false,
+ streaming: false,
+ },
+ {
+ name: "does not replay stdout callbacks",
+ failures: Infinity,
+ attempts: 1,
+ transient: true,
+ capture: true,
+ streaming: true,
+ },
+ ])(
+ "checkpoint command retry $name",
+ ({ failures, attempts: expectedAttempts, transient, capture, streaming }) =>
+ Effect.gen(function* () {
+ let attempts = 0;
+ const service = yield* VcsProcess.make.pipe(
+ Effect.provideService(ProcessRunner.ProcessRunner, {
+ run: () =>
+ Effect.sync(() => {
+ attempts += 1;
+ return {
+ stdout: "",
+ stderr:
+ attempts > failures
+ ? ""
+ : transient
+ ? "fatal: Unable to create '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/repo/index.lock': File exists"
+ : "fatal: index file corrupt",
+ code: ChildProcessSpawner.ExitCode(attempts > failures ? 0 : attempts),
+ timedOut: false,
+ stdoutTruncated: false,
+ stderrTruncated: false,
+ stdoutInvalidUtf8: false,
+ stderrInvalidUtf8: false,
+ };
+ }),
+ }),
+ );
+ const fiber = yield* service
+ .run({
+ ...baseInput,
+ operation: capture ? VcsProcess.CHECKPOINT_CAPTURE_OPERATION : baseInput.operation,
+ ...(streaming ? { onStdoutChunk: () => {} } : {}),
+ })
+ .pipe(Effect.exit, Effect.forkScoped);
+ yield* TestClock.adjust("150 millis");
+ const result = yield* Fiber.join(fiber);
+ expect(attempts).toBe(expectedAttempts);
+ if (failures === Infinity) {
+ if (Exit.isSuccess(result))
+ return yield* Effect.die("Expected a capture command failure");
+ const error = Cause.findErrorOption(result.cause);
+ if (error._tag === "None" || error.value._tag !== "VcsProcessExitError") {
+ return yield* Effect.die("Expected a Git exit error");
+ }
+ expect(error.value.exitCode).toBe(expectedAttempts);
+ } else {
+ expect(Exit.isSuccess(result)).toBe(true);
+ }
+ }),
+ );
+
it.effect("bounds a synthetic burst of GitHub API processes", () =>
Effect.gen(function* () {
const gate = yield* Deferred.make();
diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts
index fcf1cf96025a..25f3a23c368a 100644
--- a/apps/server/src/vcs/VcsProcess.ts
+++ b/apps/server/src/vcs/VcsProcess.ts
@@ -1,7 +1,9 @@
import * as Context from "effect/Context";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Match from "effect/Match";
+import * as Schedule from "effect/Schedule";
import * as Semaphore from "effect/Semaphore";
import { ChildProcessSpawner } from "effect/unstable/process";
@@ -58,6 +60,8 @@ const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
const VCS_PROCESS_CONCURRENCY = 8;
const GITHUB_PROCESS_CONCURRENCY = 4;
+export const CHECKPOINT_CAPTURE_OPERATION = "GitVcsDriver.checkpoints.captureCheckpoint";
+
const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFailureKind => {
const normalized = stderr.toLowerCase();
@@ -104,6 +108,11 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai
return "command-failed";
};
+// Classify before discarding stderr; keep paths and process output out of errors.
+const isTransientGitExit = (stderr: string) =>
+ /unable to create [^\n]*\.lock['"]?: file exists/i.test(stderr) ||
+ /(?:unable to stat|lstat\(|error: open\()[^\n]+: no such file or directory/i.test(stderr);
+
export const make = Effect.gen(function* () {
const processRunner = yield* ProcessRunner.ProcessRunner;
const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY);
@@ -167,6 +176,7 @@ export const make = Effect.gen(function* () {
}
if (!input.allowNonZeroExit && result.code !== 0) {
+ const failureKind = classifyNonZeroExit(input.command, result.stderr);
return yield* VcsProcessExitError.fromProcessExit(
baseError,
{
@@ -174,7 +184,10 @@ export const make = Effect.gen(function* () {
stderr: result.stderr,
stderrTruncated: result.stderrTruncated,
},
- classifyNonZeroExit(input.command, result.stderr),
+ failureKind,
+ input.command === "git" &&
+ failureKind === "command-failed" &&
+ isTransientGitExit(result.stderr),
);
}
@@ -191,6 +204,26 @@ export const make = Effect.gen(function* () {
const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
const bounded = vcsProcesses.withPermits(1)(runUnbounded(input));
+ if (
+ input.command === "git" &&
+ input.operation === CHECKPOINT_CAPTURE_OPERATION &&
+ input.onStdoutChunk === undefined
+ ) {
+ // Retry the failed command, retaining the private index/tree and recovery's outer deadline.
+ return yield* bounded.pipe(
+ Effect.tapError((error) =>
+ Effect.logDebug("checkpoint Git command failed", {
+ operation: input.operation,
+ errorTag: error._tag,
+ }),
+ ),
+ Effect.retry({
+ times: 2,
+ while: (error) => error._tag === "VcsProcessExitError" && error.retryable === true,
+ schedule: Schedule.spaced(Duration.millis(75)),
+ }),
+ );
+ }
return yield* input.command === "gh" ? githubProcesses.withPermits(1)(bounded) : bounded;
});
diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts
index 1f38c7cb718e..dec0e46b6eef 100644
--- a/packages/contracts/src/vcs.ts
+++ b/packages/contracts/src/vcs.ts
@@ -119,6 +119,8 @@ export class VcsProcessExitError extends Schema.TaggedError
exitCode: Schema.Number,
detail: Schema.String,
failureKind: Schema.optional(VcsProcessExitFailureKind),
+ /** Process-boundary hint for a recognized transient failure; absence is not retryable. */
+ retryable: Schema.optional(Schema.Boolean),
stderrLength: Schema.optional(NonNegativeInt),
stderrTruncated: Schema.optional(Schema.Boolean),
},
@@ -131,6 +133,7 @@ export class VcsProcessExitError extends Schema.TaggedError
context: VcsProcessErrorContext,
error: VcsProcessExitFailure,
failureKind: VcsProcessExitFailureKind,
+ retryable?: boolean,
) {
const detail =
failureKind === "authentication"
@@ -150,6 +153,7 @@ export class VcsProcessExitError extends Schema.TaggedError
exitCode: error.exitCode,
detail,
failureKind,
+ ...(retryable === true ? { retryable: true } : {}),
stderrLength: error.stderr.length,
stderrTruncated: error.stderrTruncated,
});
From 4d36142c022e0dc797034933d01e200818b4083d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 11:46:17 -0700
Subject: [PATCH 016/111] fix(mobile): keep archived threads visible during iOS
search (#12420)
---
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
index 78b3b62a3c1d..6161c203f11d 100644
--- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
+++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
@@ -636,7 +636,9 @@ export function ArchivedThreadsScreen(props: {
}, [isFiltered, isInitialLoad]);
return (
-
+ // Keep the list inside this native container. Form-sheet resizing otherwise
+ // treats the flattened background as a header and shrinks the list to zero.
+
Date: Fri, 18 Sep 2026 12:55:11 -0700
Subject: [PATCH 017/111] perf(mobile): isolate Material You conversion on
Android (#12379)
---
.../src/lib/materialYouTheme.android.ts | 108 ++++++++++++++++++
apps/mobile/src/lib/materialYouTheme.test.ts | 2 +-
apps/mobile/src/lib/materialYouTheme.ts | 104 +----------------
3 files changed, 112 insertions(+), 102 deletions(-)
create mode 100644 apps/mobile/src/lib/materialYouTheme.android.ts
diff --git a/apps/mobile/src/lib/materialYouTheme.android.ts b/apps/mobile/src/lib/materialYouTheme.android.ts
new file mode 100644
index 000000000000..ce16b23c89ec
--- /dev/null
+++ b/apps/mobile/src/lib/materialYouTheme.android.ts
@@ -0,0 +1,108 @@
+import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities";
+
+import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme";
+import type { MaterialYouPalette } from "./materialYouPalette";
+
+function withAlpha(color: string, alpha: number): string {
+ const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color);
+ if (!match?.[1]) return color;
+ const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
+ .toString(16)
+ .padStart(2, "0")
+ .toUpperCase();
+ return `#${match[1]}${alphaByte}`;
+}
+
+function materialTone(color: string, tone: number): string {
+ const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color);
+ if (!match?.[1]) return color;
+
+ const source = Hct.fromInt(argbFromHex(match[1]));
+ return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`;
+}
+
+export function materialYouPaletteToMobileThemeVariables(
+ palette: MaterialYouPalette,
+ appearance: MobileThemeAppearance,
+ base: MobileThemeVariables,
+): MobileThemeVariables {
+ const dark = appearance === "dark";
+ const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary;
+ const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary;
+
+ return {
+ ...base,
+ "--color-screen": palette.surface,
+ "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98),
+ "--color-sheet-solid": palette.surfaceContainerLow,
+ "--color-card": palette.surfaceContainer,
+ "--color-card-alt": palette.surfaceContainerHigh,
+ "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8),
+ "--color-thread-canvas": palette.surfaceContainerLow,
+ "--color-thread-selected": palette.surfaceContainer,
+ "--color-thread-selected-foreground": palette.onSurface,
+ "--color-thread-selected-foreground-muted": palette.onSurfaceVariant,
+ "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88),
+ "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94),
+ "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46),
+ "--color-foreground": palette.onSurface,
+ "--color-foreground-secondary": palette.onSurfaceVariant,
+ "--color-foreground-muted": palette.onSurfaceVariant,
+ "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58),
+ "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65),
+ "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46),
+ "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34),
+ "--color-subtle": withAlpha(palette.onSurface, 0.05),
+ "--color-subtle-strong": withAlpha(palette.onSurface, 0.1),
+ "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52),
+ "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38),
+ "--color-inline-skill-foreground": palette.onTertiaryContainer,
+ "--color-primary": palette.primary,
+ "--color-primary-foreground": palette.onPrimary,
+ "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18),
+ "--color-secondary": palette.secondaryContainer,
+ "--color-secondary-foreground": palette.onSecondaryContainer,
+ "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56),
+ "--color-switch-active-track": palette.primary,
+ "--color-switch-active-thumb": palette.onPrimary,
+ "--color-switch-inactive-track": palette.surfaceContainerHighest,
+ "--color-switch-inactive-thumb": palette.outline,
+ "--color-danger": palette.errorContainer,
+ "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5),
+ "--color-danger-foreground": palette.onErrorContainer,
+ "--color-input": palette.surfaceContainerLowest,
+ "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5),
+ "--color-sidebar-search": palette.surfaceContainerLowest,
+ "--color-placeholder": withAlpha(palette.onSurface, 0.62),
+ "--color-icon": palette.onSurface,
+ "--color-icon-muted": palette.onSurfaceVariant,
+ "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68),
+ "--color-header": palette.surfaceContainerHigh,
+ "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48),
+ "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72),
+ "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18),
+ "--color-status-bar": palette.surfaceContainerLow,
+ "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10),
+ "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10),
+ "--color-md-link": palette.primary,
+ "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26),
+ "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2),
+ "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06),
+ "--color-md-code-text": palette.onSurface,
+ "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22),
+ "--color-md-user-code-text": userBubbleForeground,
+ "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18),
+ "--color-md-user-fence-text": userBubbleForeground,
+ "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6),
+ "--color-user-bubble": userBubble,
+ "--color-user-bubble-foreground": userBubbleForeground,
+ "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78),
+ "--color-user-bubble-skill-foreground": userBubbleForeground,
+ "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22),
+ "--color-drawer": palette.surface,
+ "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12),
+ "--color-dot-separator": withAlpha(palette.onSurface, 0.2),
+ "--color-wordmark": palette.onSurface,
+ "--color-chevron": withAlpha(palette.onSurface, 0.2),
+ };
+}
diff --git a/apps/mobile/src/lib/materialYouTheme.test.ts b/apps/mobile/src/lib/materialYouTheme.test.ts
index 16618ea55d48..dc99f9badecd 100644
--- a/apps/mobile/src/lib/materialYouTheme.test.ts
+++ b/apps/mobile/src/lib/materialYouTheme.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
import type { MaterialYouPalette } from "./materialYouPalette";
-import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme";
+import { materialYouPaletteToMobileThemeVariables } from "./materialYouTheme.android";
const palette: MaterialYouPalette = {
primary: "#6750A4FF",
diff --git a/apps/mobile/src/lib/materialYouTheme.ts b/apps/mobile/src/lib/materialYouTheme.ts
index ce16b23c89ec..64d4338d1140 100644
--- a/apps/mobile/src/lib/materialYouTheme.ts
+++ b/apps/mobile/src/lib/materialYouTheme.ts
@@ -1,108 +1,10 @@
-import { Hct, argbFromHex, hexFromArgb } from "@material/material-color-utilities";
-
import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme";
import type { MaterialYouPalette } from "./materialYouPalette";
-function withAlpha(color: string, alpha: number): string {
- const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color);
- if (!match?.[1]) return color;
- const alphaByte = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
- .toString(16)
- .padStart(2, "0")
- .toUpperCase();
- return `#${match[1]}${alphaByte}`;
-}
-
-function materialTone(color: string, tone: number): string {
- const match = /^#([\da-f]{6})(?:[\da-f]{2})?$/iu.exec(color);
- if (!match?.[1]) return color;
-
- const source = Hct.fromInt(argbFromHex(match[1]));
- return `${hexFromArgb(Hct.from(source.hue, source.chroma, tone).toInt()).toUpperCase()}FF`;
-}
-
export function materialYouPaletteToMobileThemeVariables(
- palette: MaterialYouPalette,
- appearance: MobileThemeAppearance,
+ _palette: MaterialYouPalette,
+ _appearance: MobileThemeAppearance,
base: MobileThemeVariables,
): MobileThemeVariables {
- const dark = appearance === "dark";
- const userBubble = dark ? materialTone(palette.inversePrimary, 30) : palette.primary;
- const userBubbleForeground = dark ? materialTone(palette.inversePrimary, 90) : palette.onPrimary;
-
- return {
- ...base,
- "--color-screen": palette.surface,
- "--color-sheet": withAlpha(palette.surfaceContainerLow, 0.98),
- "--color-sheet-solid": palette.surfaceContainerLow,
- "--color-card": palette.surfaceContainer,
- "--color-card-alt": palette.surfaceContainerHigh,
- "--color-card-translucent": withAlpha(palette.surfaceContainer, 0.8),
- "--color-thread-canvas": palette.surfaceContainerLow,
- "--color-thread-selected": palette.surfaceContainer,
- "--color-thread-selected-foreground": palette.onSurface,
- "--color-thread-selected-foreground-muted": palette.onSurfaceVariant,
- "--color-composer-panel": withAlpha(palette.surfaceContainerLow, dark ? 0.92 : 0.88),
- "--color-composer-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.9 : 0.94),
- "--color-composer-border": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46),
- "--color-foreground": palette.onSurface,
- "--color-foreground-secondary": palette.onSurfaceVariant,
- "--color-foreground-muted": palette.onSurfaceVariant,
- "--color-foreground-tertiary": withAlpha(palette.onSurfaceVariant, 0.58),
- "--color-border": withAlpha(palette.outlineVariant, dark ? 0.5 : 0.65),
- "--color-border-subtle": withAlpha(palette.outlineVariant, dark ? 0.34 : 0.46),
- "--color-separator": withAlpha(palette.outlineVariant, dark ? 0.24 : 0.34),
- "--color-subtle": withAlpha(palette.onSurface, 0.05),
- "--color-subtle-strong": withAlpha(palette.onSurface, 0.1),
- "--color-inline-skill-background": withAlpha(palette.tertiaryContainer, 0.52),
- "--color-inline-skill-border": withAlpha(palette.tertiary, 0.38),
- "--color-inline-skill-foreground": palette.onTertiaryContainer,
- "--color-primary": palette.primary,
- "--color-primary-foreground": palette.onPrimary,
- "--color-primary-shadow": withAlpha(palette.scrim, dark ? 0.22 : 0.18),
- "--color-secondary": palette.secondaryContainer,
- "--color-secondary-foreground": palette.onSecondaryContainer,
- "--color-secondary-border": withAlpha(palette.outlineVariant, dark ? 0.42 : 0.56),
- "--color-switch-active-track": palette.primary,
- "--color-switch-active-thumb": palette.onPrimary,
- "--color-switch-inactive-track": palette.surfaceContainerHighest,
- "--color-switch-inactive-thumb": palette.outline,
- "--color-danger": palette.errorContainer,
- "--color-danger-border": withAlpha(palette.onErrorContainer, dark ? 0.42 : 0.5),
- "--color-danger-foreground": palette.onErrorContainer,
- "--color-input": palette.surfaceContainerLowest,
- "--color-input-border": withAlpha(palette.outline, dark ? 0.42 : 0.5),
- "--color-sidebar-search": palette.surfaceContainerLowest,
- "--color-placeholder": withAlpha(palette.onSurface, 0.62),
- "--color-icon": palette.onSurface,
- "--color-icon-muted": palette.onSurfaceVariant,
- "--color-icon-subtle": withAlpha(palette.onSurfaceVariant, 0.68),
- "--color-header": palette.surfaceContainerHigh,
- "--color-header-border": withAlpha(palette.outlineVariant, dark ? 0.36 : 0.48),
- "--color-glass-surface": withAlpha(palette.surfaceContainerHigh, dark ? 0.78 : 0.72),
- "--color-glass-tint": withAlpha(palette.primaryContainer, dark ? 0.24 : 0.18),
- "--color-status-bar": palette.surfaceContainerLow,
- "--color-md-body": materialTone(palette.onSurfaceVariant, dark ? 90 : 10),
- "--color-md-strong": materialTone(palette.onSurfaceVariant, dark ? 90 : 10),
- "--color-md-link": palette.primary,
- "--color-md-blockquote-border": withAlpha(palette.primary, dark ? 0.34 : 0.26),
- "--color-md-blockquote-bg": withAlpha(palette.primaryContainer, dark ? 0.14 : 0.2),
- "--color-md-code-bg": withAlpha(palette.onSurface, dark ? 0.08 : 0.06),
- "--color-md-code-text": palette.onSurface,
- "--color-md-user-code-bg": withAlpha(userBubbleForeground, dark ? 0.18 : 0.22),
- "--color-md-user-code-text": userBubbleForeground,
- "--color-md-user-fence-bg": withAlpha(userBubbleForeground, dark ? 0.14 : 0.18),
- "--color-md-user-fence-text": userBubbleForeground,
- "--color-md-hr": withAlpha(palette.outlineVariant, dark ? 0.48 : 0.6),
- "--color-user-bubble": userBubble,
- "--color-user-bubble-foreground": userBubbleForeground,
- "--color-user-bubble-foreground-muted": withAlpha(userBubbleForeground, 0.78),
- "--color-user-bubble-skill-foreground": userBubbleForeground,
- "--color-backdrop": withAlpha(palette.scrim, dark ? 0.48 : 0.22),
- "--color-drawer": palette.surface,
- "--color-drawer-shadow": withAlpha(palette.scrim, dark ? 0.32 : 0.12),
- "--color-dot-separator": withAlpha(palette.onSurface, 0.2),
- "--color-wordmark": palette.onSurface,
- "--color-chevron": withAlpha(palette.onSurface, 0.2),
- };
+ return base;
}
From 53830d413474b2a5749c040d4382c96002894b88 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:12 -0700
Subject: [PATCH 018/111] perf(mobile): isolate iOS Live Activity imports
(#12380)
---
.../agent-awareness/agentLiveActivity.ios.ts | 9 +++++++++
.../agent-awareness/agentLiveActivity.ts | 12 ++++++++++++
.../agent-awareness/remoteRegistration.test.ts | 8 +++-----
.../agent-awareness/remoteRegistration.ts | 18 +++++++++++-------
4 files changed, 35 insertions(+), 12 deletions(-)
create mode 100644 apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts
create mode 100644 apps/mobile/src/features/agent-awareness/agentLiveActivity.ts
diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts
new file mode 100644
index 000000000000..ab078b29a665
--- /dev/null
+++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts
@@ -0,0 +1,9 @@
+import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
+
+export function getAgentLiveActivities() {
+ return AgentActivity.getInstances();
+}
+
+export function startAgentLiveActivity(props: AgentActivityProps) {
+ return AgentActivity.start(props);
+}
diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts
new file mode 100644
index 000000000000..44ac017984d9
--- /dev/null
+++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts
@@ -0,0 +1,12 @@
+import type { LiveActivity } from "expo-widgets";
+import type { AgentActivityProps } from "../../widgets/AgentActivity";
+
+export function getAgentLiveActivities(): Array> {
+ return [];
+}
+
+export function startAgentLiveActivity(
+ _props: AgentActivityProps,
+): LiveActivity | null {
+ return null;
+}
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
index bd4109c275e6..df8e023710bd 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts
@@ -101,11 +101,9 @@ vi.mock("expo-widgets", () => ({
addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
}));
-vi.mock("../../widgets/AgentActivity", () => ({
- default: {
- getInstances: widgetMocks.getInstances,
- start: widgetMocks.start,
- },
+vi.mock("./agentLiveActivity", () => ({
+ getAgentLiveActivities: widgetMocks.getInstances,
+ startAgentLiveActivity: widgetMocks.start,
}));
// The state modules pull the whole connection stack (and native expo modules)
diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
index 5b4ca2423249..a6c31594327a 100644
--- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
+++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts
@@ -35,7 +35,8 @@ import {
loadPreferences,
saveAgentAwarenessRegistrationRecord,
} from "../../persistence/imperative";
-import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
+import type { AgentActivityProps } from "../../widgets/AgentActivity";
+import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity";
import { resolveCloudPublicConfig } from "../cloud/publicConfig";
import { supportsAgentAwarenessPush } from "./capabilities";
import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
@@ -522,11 +523,11 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
readonly projectTitle: string;
}): void {
try {
- if (AgentActivity.getInstances().length > 0) {
+ if (getAgentLiveActivities().length > 0) {
return;
}
const nowIso = new Date(Date.now()).toISOString();
- const activity = AgentActivity.start({
+ const activity = startAgentLiveActivity({
title: "T3 Code",
subtitle: "Agent work in progress",
activeCount: 1,
@@ -545,6 +546,9 @@ function armAgentAwarenessLiveActivityForLocalWorkNow(input: {
},
],
});
+ if (!activity) {
+ return;
+ }
logRegistrationDebug("live activity card armed for local work", {
threadTitle: input.threadTitle,
});
@@ -842,7 +846,7 @@ function endLocalLiveActivities(context: string): void {
return;
}
try {
- for (const activity of AgentActivity.getInstances()) {
+ for (const activity of getAgentLiveActivities()) {
activity.end("immediate").catch((error: unknown) => {
logRegistrationError(context, error);
});
@@ -1069,7 +1073,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
}
let activities = yield* Effect.try({
- try: () => AgentActivity.getInstances(),
+ try: () => getAgentLiveActivities(),
catch: (cause) =>
new AgentAwarenessOperationError({
operation: "list-active-live-activities",
@@ -1118,7 +1122,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
// The snapshot request yields; an arm-on-send may have created the
// card in the meantime. Re-check so two cards are never started.
const armedMeanwhile = yield* Effect.try({
- try: () => AgentActivity.getInstances(),
+ try: () => getAgentLiveActivities(),
catch: () => [] as ReadonlyArray>,
}).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray>));
if (armedMeanwhile.length > 0) {
@@ -1127,7 +1131,7 @@ export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
const aggregate = snapshot.aggregate;
const primed = yield* Effect.try({
try: () =>
- AgentActivity.start({
+ startAgentLiveActivity({
title: aggregate.title,
subtitle: aggregate.subtitle,
activeCount: aggregate.activeCount,
From 7e7cd32463b528838cb10ef6b89ef6ceac1f7a4c Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:12 -0700
Subject: [PATCH 019/111] refactor(mobile): split home headers by platform
(#12381)
---
.../src/features/home/HomeHeader.android.tsx | 161 +++++++++++++
apps/mobile/src/features/home/HomeHeader.tsx | 217 ++----------------
.../src/features/home/HomeHeader.types.ts | 26 +++
apps/mobile/src/features/home/HomeScreen.tsx | 2 +-
4 files changed, 203 insertions(+), 203 deletions(-)
create mode 100644 apps/mobile/src/features/home/HomeHeader.android.tsx
create mode 100644 apps/mobile/src/features/home/HomeHeader.types.ts
diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx
new file mode 100644
index 000000000000..78e87431b471
--- /dev/null
+++ b/apps/mobile/src/features/home/HomeHeader.android.tsx
@@ -0,0 +1,161 @@
+import type { MenuAction } from "@react-native-menu/menu";
+import { useCallback, useMemo } from "react";
+import { NativeStackScreenOptions } from "../../native/StackHeader";
+import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
+import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
+import {
+ hasCustomHomeListOptions,
+ PROJECT_SORT_OPTIONS,
+ THREAD_SORT_OPTIONS,
+} from "./home-list-options";
+import type { HomeHeaderProps } from "./HomeHeader.types";
+
+export type { HomeHeaderEnvironment } from "./HomeHeader.types";
+
+function checkedMenuState(checked: boolean) {
+ return checked ? ("on" as const) : undefined;
+}
+
+export function HomeHeader(props: HomeHeaderProps) {
+ // Thread List v2 lays the list out in fixed creation order, so the
+ // sort/group filter controls would be silently ignored — hide them and
+ // key the "customized" icon state off the environment filter alone.
+ const threadListV2Enabled = useThreadListV2Enabled();
+ const hasCustomListOptions = threadListV2Enabled
+ ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
+ : hasCustomHomeListOptions(props);
+ const menuActions = useMemo(
+ () => [
+ {
+ id: "environment",
+ title: "Environment",
+ subactions: [
+ {
+ id: "environment:all",
+ title: "All environments",
+ state: checkedMenuState(props.selectedEnvironmentId === null),
+ },
+ ...props.environments.map((environment) => ({
+ id: `environment:${environment.environmentId}`,
+ title: environment.label,
+ state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId),
+ })),
+ ],
+ },
+ ...(props.projects.length === 0
+ ? []
+ : ([
+ {
+ id: "project",
+ title: "Project",
+ subactions: [
+ {
+ id: "project:all",
+ title: "All projects",
+ state: checkedMenuState(props.selectedProjectKey === null),
+ },
+ ...props.projects.map((project) => ({
+ id: `project:${project.key}`,
+ title: project.label,
+ state: checkedMenuState(props.selectedProjectKey === project.key),
+ })),
+ ],
+ },
+ ] satisfies MenuAction[])),
+ ...(threadListV2Enabled
+ ? []
+ : ([
+ {
+ id: "project-sort",
+ title: "Sort projects",
+ subactions: PROJECT_SORT_OPTIONS.map((option) => ({
+ id: `project-sort:${option.value}`,
+ title: option.label,
+ state: checkedMenuState(props.projectSortOrder === option.value),
+ })),
+ },
+ {
+ id: "thread-sort",
+ title: "Sort threads",
+ subactions: THREAD_SORT_OPTIONS.map((option) => ({
+ id: `thread-sort:${option.value}`,
+ title: option.label,
+ state: checkedMenuState(props.threadSortOrder === option.value),
+ })),
+ },
+ ] satisfies MenuAction[])),
+ ],
+ [
+ props.environments,
+ props.projectSortOrder,
+ props.projects,
+ props.selectedEnvironmentId,
+ props.selectedProjectKey,
+ props.threadSortOrder,
+ threadListV2Enabled,
+ ],
+ );
+ const handleMenuAction = useCallback(
+ (event: { nativeEvent: { event: string } }) => {
+ const id = event.nativeEvent.event;
+ if (id === "environment:all") {
+ props.onEnvironmentChange(null);
+ return;
+ }
+
+ if (id.startsWith("environment:")) {
+ const environmentId = id.slice("environment:".length);
+ const environment = props.environments.find(
+ (candidate) => candidate.environmentId === environmentId,
+ );
+ if (environment) {
+ props.onEnvironmentChange(environment.environmentId);
+ }
+ return;
+ }
+
+ if (id === "project:all") {
+ props.onProjectChange(null);
+ return;
+ }
+
+ if (id.startsWith("project:")) {
+ const projectKey = id.slice("project:".length);
+ if (props.projects.some((project) => project.key === projectKey)) {
+ props.onProjectChange(projectKey);
+ }
+ return;
+ }
+
+ const projectSort = PROJECT_SORT_OPTIONS.find(
+ (option) => id === `project-sort:${option.value}`,
+ );
+ if (projectSort) {
+ props.onProjectSortOrderChange(projectSort.value);
+ return;
+ }
+
+ const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);
+ if (threadSort) {
+ props.onThreadSortOrderChange(threadSort.value);
+ return;
+ }
+ },
+ [props],
+ );
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx
index bf4c28aa5474..2f044862f703 100644
--- a/apps/mobile/src/features/home/HomeHeader.tsx
+++ b/apps/mobile/src/features/home/HomeHeader.tsx
@@ -1,11 +1,6 @@
-import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
-import type { MenuAction } from "@react-native-menu/menu";
-
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
-import { useCallback, useMemo, useRef } from "react";
-import { Platform } from "react-native";
+import { useCallback, useRef } from "react";
import type { SearchBarCommands } from "react-native-screens";
-
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
@@ -14,196 +9,17 @@ import {
createNativeMailSearchToolbarItem,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
-import type { HomeProjectSortOrder } from "./homeThreadList";
-import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar";
-import {
- buildHomeListFilterMenu,
- type HomeListFilterMenuEnvironment,
- type HomeListFilterMenuProject,
-} from "./home-list-filter-menu";
+import { buildHomeListFilterMenu } from "./home-list-filter-menu";
import {
hasCustomHomeListOptions,
PROJECT_SORT_OPTIONS,
THREAD_SORT_OPTIONS,
} from "./home-list-options";
+import type { HomeHeaderProps } from "./HomeHeader.types";
-export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
-
-export function HomeHeader(props: {
- readonly environments: ReadonlyArray;
- readonly projects: ReadonlyArray;
- readonly searchQuery: string;
- readonly selectedEnvironmentId: EnvironmentId | null;
- readonly selectedProjectKey: string | null;
- readonly projectSortOrder: HomeProjectSortOrder;
- readonly threadSortOrder: SidebarThreadSortOrder;
- readonly onSearchQueryChange: (query: string) => void;
- readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
- readonly onProjectChange: (projectKey: string | null) => void;
- readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
- readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
- readonly onOpenEnvironments: () => void;
- readonly onOpenSettings: () => void;
- readonly onStartNewTask: () => void;
-}) {
- if (Platform.OS === "android") {
- return ;
- }
-
- return ;
-}
-
-type HomeHeaderProps = Parameters[0];
-
-function checkedMenuState(checked: boolean) {
- return checked ? ("on" as const) : undefined;
-}
+export type { HomeHeaderEnvironment } from "./HomeHeader.types";
-function AndroidHomeHeader(props: HomeHeaderProps) {
- // Thread List v2 lays the list out in fixed creation order, so the
- // sort/group filter controls would be silently ignored — hide them and
- // key the "customized" icon state off the environment filter alone.
- const threadListV2Enabled = useThreadListV2Enabled();
- const hasCustomListOptions = threadListV2Enabled
- ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null
- : hasCustomHomeListOptions(props);
- const menuActions = useMemo(
- () => [
- {
- id: "environment",
- title: "Environment",
- subactions: [
- {
- id: "environment:all",
- title: "All environments",
- state: checkedMenuState(props.selectedEnvironmentId === null),
- },
- ...props.environments.map((environment) => ({
- id: `environment:${environment.environmentId}`,
- title: environment.label,
- state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId),
- })),
- ],
- },
- ...(props.projects.length === 0
- ? []
- : ([
- {
- id: "project",
- title: "Project",
- subactions: [
- {
- id: "project:all",
- title: "All projects",
- state: checkedMenuState(props.selectedProjectKey === null),
- },
- ...props.projects.map((project) => ({
- id: `project:${project.key}`,
- title: project.label,
- state: checkedMenuState(props.selectedProjectKey === project.key),
- })),
- ],
- },
- ] satisfies MenuAction[])),
- ...(threadListV2Enabled
- ? []
- : ([
- {
- id: "project-sort",
- title: "Sort projects",
- subactions: PROJECT_SORT_OPTIONS.map((option) => ({
- id: `project-sort:${option.value}`,
- title: option.label,
- state: checkedMenuState(props.projectSortOrder === option.value),
- })),
- },
- {
- id: "thread-sort",
- title: "Sort threads",
- subactions: THREAD_SORT_OPTIONS.map((option) => ({
- id: `thread-sort:${option.value}`,
- title: option.label,
- state: checkedMenuState(props.threadSortOrder === option.value),
- })),
- },
- ] satisfies MenuAction[])),
- ],
- [
- props.environments,
- props.projectSortOrder,
- props.projects,
- props.selectedEnvironmentId,
- props.selectedProjectKey,
- props.threadSortOrder,
- threadListV2Enabled,
- ],
- );
- const handleMenuAction = useCallback(
- (event: { nativeEvent: { event: string } }) => {
- const id = event.nativeEvent.event;
- if (id === "environment:all") {
- props.onEnvironmentChange(null);
- return;
- }
-
- if (id.startsWith("environment:")) {
- const environmentId = id.slice("environment:".length);
- const environment = props.environments.find(
- (candidate) => candidate.environmentId === environmentId,
- );
- if (environment) {
- props.onEnvironmentChange(environment.environmentId);
- }
- return;
- }
-
- if (id === "project:all") {
- props.onProjectChange(null);
- return;
- }
-
- if (id.startsWith("project:")) {
- const projectKey = id.slice("project:".length);
- if (props.projects.some((project) => project.key === projectKey)) {
- props.onProjectChange(projectKey);
- }
- return;
- }
-
- const projectSort = PROJECT_SORT_OPTIONS.find(
- (option) => id === `project-sort:${option.value}`,
- );
- if (projectSort) {
- props.onProjectSortOrderChange(projectSort.value);
- return;
- }
-
- const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`);
- if (threadSort) {
- props.onThreadSortOrderChange(threadSort.value);
- return;
- }
- },
- [props],
- );
-
- return (
- <>
-
-
- >
- );
-}
-
-function IosHomeHeader(props: HomeHeaderProps) {
+export function HomeHeader(props: HomeHeaderProps) {
const searchBarRef = useRef(null);
const iconColor = useUniwindTheme()["--color-icon"];
// Thread List v2 lays the list out in fixed creation order, so the
@@ -231,19 +47,16 @@ function IosHomeHeader(props: HomeHeaderProps) {
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
- unstable_headerRightItems:
- Platform.OS === "ios"
- ? () => [
- withNativeGlassHeaderItem({
- accessibilityLabel: "Open settings",
- icon: { name: "ellipsis", type: "sfSymbol" } as const,
- identifier: "home-settings",
- label: "",
- onPress: props.onOpenSettings,
- type: "button",
- }),
- ]
- : undefined,
+ unstable_headerRightItems: () => [
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Open settings",
+ icon: { name: "ellipsis", type: "sfSymbol" } as const,
+ identifier: "home-settings",
+ label: "",
+ onPress: props.onOpenSettings,
+ type: "button",
+ }),
+ ],
// The keys below are set per-branch (not `undefined`) so a later
// reapply cannot clobber options owned by NativeHeaderToolbar.
...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
diff --git a/apps/mobile/src/features/home/HomeHeader.types.ts b/apps/mobile/src/features/home/HomeHeader.types.ts
new file mode 100644
index 000000000000..0545db092817
--- /dev/null
+++ b/apps/mobile/src/features/home/HomeHeader.types.ts
@@ -0,0 +1,26 @@
+import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts";
+import type { HomeProjectSortOrder } from "./homeThreadList";
+import type {
+ HomeListFilterMenuEnvironment,
+ HomeListFilterMenuProject,
+} from "./home-list-filter-menu";
+
+export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment;
+
+export interface HomeHeaderProps {
+ readonly environments: ReadonlyArray;
+ readonly projects: ReadonlyArray;
+ readonly searchQuery: string;
+ readonly selectedEnvironmentId: EnvironmentId | null;
+ readonly selectedProjectKey: string | null;
+ readonly projectSortOrder: HomeProjectSortOrder;
+ readonly threadSortOrder: SidebarThreadSortOrder;
+ readonly onSearchQueryChange: (query: string) => void;
+ readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void;
+ readonly onProjectChange: (projectKey: string | null) => void;
+ readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void;
+ readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void;
+ readonly onOpenEnvironments: () => void;
+ readonly onOpenSettings: () => void;
+ readonly onStartNewTask: () => void;
+}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index c0afbfed2089..a01f5fbe606e 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -141,7 +141,7 @@ const ESTIMATED_THREAD_ROW_HEIGHT = 72;
const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
/**
* Top spacing between the list and the Android custom header. The Android
- * header (AndroidHomeHeader) is rendered in-flow above this screen and
+ * header is rendered in-flow above this screen and
* already consumes the top safe-area inset, so the list only needs breathing
* room here.
*/
From 429d66c13666c4e243065bbd02bee48e1eea2307 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:12 -0700
Subject: [PATCH 020/111] refactor(mobile): split native menus by platform
(#12382)
---
apps/mobile/src/components/ControlPill.tsx | 172 +-----------------
.../components/ControlPillMenu.android.tsx | 43 +++++
.../mobile/src/components/ControlPillMenu.tsx | 115 ++++++++++++
.../src/components/ControlPillMenu.types.ts | 12 ++
4 files changed, 173 insertions(+), 169 deletions(-)
create mode 100644 apps/mobile/src/components/ControlPillMenu.android.tsx
create mode 100644 apps/mobile/src/components/ControlPillMenu.tsx
create mode 100644 apps/mobile/src/components/ControlPillMenu.types.ts
diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx
index ba75beeeb554..1a1a84c31bc8 100644
--- a/apps/mobile/src/components/ControlPill.tsx
+++ b/apps/mobile/src/components/ControlPill.tsx
@@ -1,59 +1,12 @@
-import { MenuView } from "@react-native-menu/menu";
-import * as Haptics from "expo-haptics";
-import {
- cloneElement,
- isValidElement,
- type ComponentProps,
- type ReactElement,
- type ReactNode,
- useMemo,
- useRef,
-} from "react";
-import {
- Platform,
- Pressable,
- View,
- type ColorValue,
- type PressableProps,
- type AccessibilityProps,
-} from "react-native";
-import { withUniwind } from "uniwind";
-import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { type ComponentProps, type ReactNode, useRef } from "react";
+import { Platform, Pressable, View } from "react-native";
import { cn } from "../lib/cn";
-import { withMenuActionIconColors } from "../lib/menu-action-colors";
-import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
import { SymbolView } from "./AppSymbol";
import { AppText as Text } from "./AppText";
import { MaterialIconButton } from "./MaterialIconButton";
import { MaterialButton } from "./MaterialButton";
-const ThemedMenuView = withUniwind(
- function NativeMenuView({
- iconColor,
- destructiveIconColor,
- ...props
- }: ComponentProps & {
- readonly iconColor?: ColorValue;
- readonly destructiveIconColor?: ColorValue;
- }) {
- const actions = useMemo(
- () =>
- withMenuActionIconColors(props.actions, {
- icon: iconColor,
- destructiveIcon: destructiveIconColor,
- }),
- [props.actions, iconColor, destructiveIconColor],
- );
- return ;
- },
- {
- iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" },
- destructiveIconColor: {
- fromClassName: "destructiveIconColorClassName",
- styleProperty: "accentColor",
- },
- },
-);
+export { ControlPillMenu } from "./ControlPillMenu";
export function ControlPill(props: {
readonly icon?: ComponentProps["name"];
@@ -187,122 +140,3 @@ export function ControlPill(props: {
);
}
-
-// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the
-// selected Android appearance and keeps editor menus above the keyboard.
-export function ControlPillMenu(
- props: Omit, "children" | "themeVariant"> &
- Pick & {
- readonly children: ReactNode;
- readonly className?: string;
- },
-) {
- const { themeAppearance } = useAppearancePreferences();
- const isDarkMode = themeAppearance === "dark";
- const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false });
- const pendingPress = useRef<(() => void) | null>(null);
-
- if (Platform.OS === "android") {
- // Long-press menus keep their child interactive: the child element gets
- // an injected onLongPress (mirroring the iOS context-menu interaction)
- // so its own tap handling still works.
- if (props.shouldOpenOnLongPress && isValidElement(props.children)) {
- const child = props.children as ReactElement<{ onLongPress?: () => void }>;
- return (
-
- {(open) =>
- cloneElement(child, {
- onLongPress: () => {
- void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
- open();
- },
- })
- }
-
- );
- }
- return (
-
- {props.children}
-
- );
- }
-
- const { className: _className, ...menuProps } = props;
- let children = menuProps.children;
- if (props.shouldOpenOnLongPress && isValidElement(children)) {
- const child = children as ReactElement>;
- children = cloneElement(child, {
- onTouchStart: (event) => {
- // Reset for a new touch, not onPressIn, which also fires when a
- // finger moves out of the row and back during the same gesture.
- menuPress.current.isPreparing = false;
- menuPress.current.suppressPress = menuPress.current.isOpen;
- pendingPress.current = null;
- child.props.onTouchStart?.(event);
- },
- onPress: (event) => {
- // Accessibility clicks have no touch identifier and must not inherit
- // cancellation from a previous physical gesture.
- const isTouch = typeof event.nativeEvent.identifier === "number";
- if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) {
- return;
- }
- if (isTouch && menuPress.current.isPreparing) {
- // A release can arrive between native menu preparation and display.
- // Let UIKit's display/cancel callback decide this press's outcome.
- event.persist();
- pendingPress.current = () => child.props.onPress?.(event);
- return;
- }
- child.props.onPress?.(event);
- },
- });
- menuProps.onMenuInteractionStart = () => {
- menuPress.current.isPreparing = true;
- props.onMenuInteractionStart?.();
- };
- menuProps.onOpenMenu = () => {
- menuPress.current.isPreparing = false;
- menuPress.current.isOpen = true;
- menuPress.current.suppressPress = true;
- pendingPress.current = null;
- props.onOpenMenu?.();
- };
- menuProps.onCloseMenu = () => {
- menuPress.current.isPreparing = false;
- menuPress.current.isOpen = false;
- // Keep this gesture cancelled even if dismissal precedes finger-up.
- // A separate JS long-press timer would also swallow holds that never
- // open the native menu.
- const press = pendingPress.current;
- pendingPress.current = null;
- props.onCloseMenu?.();
- if (!menuPress.current.suppressPress) {
- press?.();
- }
- };
- }
- return (
-
- {children}
-
- );
-}
diff --git a/apps/mobile/src/components/ControlPillMenu.android.tsx b/apps/mobile/src/components/ControlPillMenu.android.tsx
new file mode 100644
index 000000000000..984086eb8b13
--- /dev/null
+++ b/apps/mobile/src/components/ControlPillMenu.android.tsx
@@ -0,0 +1,43 @@
+import * as Haptics from "expo-haptics";
+import { cloneElement, isValidElement, type ReactElement } from "react";
+import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
+import type { ControlPillMenuProps } from "./ControlPillMenu.types";
+
+/** Uses the same actions as iOS menus, with Android appearance and editor anchoring. */
+export function ControlPillMenu(props: ControlPillMenuProps) {
+ // Long-press menus keep their child interactive: the child element gets
+ // an injected onLongPress (mirroring the iOS context-menu interaction)
+ // so its own tap handling still works.
+ if (props.shouldOpenOnLongPress && isValidElement(props.children)) {
+ const child = props.children as ReactElement<{ onLongPress?: () => void }>;
+ return (
+
+ {(open) =>
+ cloneElement(child, {
+ onLongPress: () => {
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ open();
+ },
+ })
+ }
+
+ );
+ }
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/apps/mobile/src/components/ControlPillMenu.tsx b/apps/mobile/src/components/ControlPillMenu.tsx
new file mode 100644
index 000000000000..b2ddb54e4544
--- /dev/null
+++ b/apps/mobile/src/components/ControlPillMenu.tsx
@@ -0,0 +1,115 @@
+import { MenuView } from "@react-native-menu/menu";
+import {
+ cloneElement,
+ isValidElement,
+ useMemo,
+ useRef,
+ type ComponentProps,
+ type ReactElement,
+} from "react";
+import type { ColorValue, PressableProps } from "react-native";
+import { withUniwind } from "uniwind";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { withMenuActionIconColors } from "../lib/menu-action-colors";
+import type { ControlPillMenuProps } from "./ControlPillMenu.types";
+
+const ThemedMenuView = withUniwind(
+ function NativeMenuView({
+ iconColor,
+ destructiveIconColor,
+ ...props
+ }: ComponentProps & {
+ readonly iconColor?: ColorValue;
+ readonly destructiveIconColor?: ColorValue;
+ }) {
+ const actions = useMemo(
+ () =>
+ withMenuActionIconColors(props.actions, {
+ icon: iconColor,
+ destructiveIcon: destructiveIconColor,
+ }),
+ [props.actions, iconColor, destructiveIconColor],
+ );
+ return ;
+ },
+ {
+ iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" },
+ destructiveIconColor: {
+ fromClassName: "destructiveIconColorClassName",
+ styleProperty: "accentColor",
+ },
+ },
+);
+
+export function ControlPillMenu(props: ControlPillMenuProps) {
+ const { themeAppearance } = useAppearancePreferences();
+ const isDarkMode = themeAppearance === "dark";
+ const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false });
+ const pendingPress = useRef<(() => void) | null>(null);
+
+ const { className: _className, ...menuProps } = props;
+ let children = menuProps.children;
+ if (props.shouldOpenOnLongPress && isValidElement(children)) {
+ const child = children as ReactElement>;
+ children = cloneElement(child, {
+ onTouchStart: (event) => {
+ // Reset for a new touch, not onPressIn, which also fires when a
+ // finger moves out of the row and back during the same gesture.
+ menuPress.current.isPreparing = false;
+ menuPress.current.suppressPress = menuPress.current.isOpen;
+ pendingPress.current = null;
+ child.props.onTouchStart?.(event);
+ },
+ onPress: (event) => {
+ // Accessibility clicks have no touch identifier and must not inherit
+ // cancellation from a previous physical gesture.
+ const isTouch = typeof event.nativeEvent.identifier === "number";
+ if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) {
+ return;
+ }
+ if (isTouch && menuPress.current.isPreparing) {
+ // A release can arrive between native menu preparation and display.
+ // Let UIKit's display/cancel callback decide this press's outcome.
+ event.persist();
+ pendingPress.current = () => child.props.onPress?.(event);
+ return;
+ }
+ child.props.onPress?.(event);
+ },
+ });
+ menuProps.onMenuInteractionStart = () => {
+ menuPress.current.isPreparing = true;
+ props.onMenuInteractionStart?.();
+ };
+ menuProps.onOpenMenu = () => {
+ menuPress.current.isPreparing = false;
+ menuPress.current.isOpen = true;
+ menuPress.current.suppressPress = true;
+ pendingPress.current = null;
+ props.onOpenMenu?.();
+ };
+ menuProps.onCloseMenu = () => {
+ menuPress.current.isPreparing = false;
+ menuPress.current.isOpen = false;
+ // Keep this gesture cancelled even if dismissal precedes finger-up.
+ // A separate JS long-press timer would also swallow holds that never
+ // open the native menu.
+ const press = pendingPress.current;
+ pendingPress.current = null;
+ props.onCloseMenu?.();
+ if (!menuPress.current.suppressPress) {
+ press?.();
+ }
+ };
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/mobile/src/components/ControlPillMenu.types.ts b/apps/mobile/src/components/ControlPillMenu.types.ts
new file mode 100644
index 000000000000..eb8d58b6669c
--- /dev/null
+++ b/apps/mobile/src/components/ControlPillMenu.types.ts
@@ -0,0 +1,12 @@
+import type { MenuView } from "@react-native-menu/menu";
+import type { ComponentProps, ReactNode } from "react";
+import type { AccessibilityProps } from "react-native";
+
+export type ControlPillMenuProps = Omit<
+ ComponentProps,
+ "children" | "themeVariant"
+> &
+ Pick & {
+ readonly children: ReactNode;
+ readonly className?: string;
+ };
From dec0732281f5b7e787ad3a84e5cbe9a1607ea4ca Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:13 -0700
Subject: [PATCH 021/111] refactor(mobile): isolate thread row appearance by
platform (#12383)
---
.../features/threads/thread-list-v2-items.tsx | 158 ++++--------------
.../thread-list-v2-row-appearance.android.ts | 46 +++++
.../threads/thread-list-v2-row-appearance.ts | 49 ++++++
3 files changed, 129 insertions(+), 124 deletions(-)
create mode 100644 apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts
create mode 100644 apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index a1b16ee21ffd..bd7b481c337f 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -1,3 +1,11 @@
+import {
+ THREAD_LIST_V2_MONO_FONT as MONO_FONT,
+ THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME,
+ THREAD_LIST_V2_ROW_DIVIDERS,
+ selectedThreadRowColors,
+ getThreadListV2NewBranchMenuTitle,
+ getThreadListV2RowAppearance,
+} from "./thread-list-v2-row-appearance";
import { RowPressable } from "../../components/RowPressable";
import { CustomSnoozeSheet } from "./CustomSnoozeSheet";
import { appAtomRegistry } from "../../state/atom-registry";
@@ -13,7 +21,7 @@ import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/t
import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
-import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native";
+import { Alert, Pressable, useWindowDimensions, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { SymbolView } from "../../components/AppSymbol";
@@ -49,12 +57,6 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";
* hierarchy rather than card fills.
*/
-const MONO_FONT = Platform.select({
- ios: "Menlo",
- android: "monospace",
- default: "monospace",
-});
-
// Status hues follow the system-wide convention set by sidebar v1 and the
// Live Activity/widgets (amber approval, indigo input, sky working) so a
// thread reads the same color everywhere it surfaces.
@@ -477,23 +479,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
const pr = useThreadPr(thread);
const theme = useUniwindTheme();
- const screenColor = theme["--color-screen"];
- const drawerColor = theme["--color-drawer"];
- const selectedBackgroundColor =
- theme[Platform.OS === "android" ? "--color-thread-selected" : "--color-user-bubble"];
const sidebarPane = props.pane === "sidebar";
const selected = props.selected === true;
- // The provider badge's border blends into the row's own surface, which
- // differs by pane and (for the sidebar pane) selection: the sidebar row
- // background becomes the selected fill or the drawer surface, while the
- // flat "screen" pane rows always sit on the screen background.
- const providerIconSurfaceColor = sidebarPane
- ? selected
- ? selectedBackgroundColor
- : Platform.OS === "android"
- ? screenColor
- : drawerColor
- : screenColor;
+ const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected);
const status = resolveThreadListV2Status(thread);
const statusLabel = STATUS_LABEL_BY_STATUS[status];
@@ -788,11 +776,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
@@ -811,9 +795,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
className={cn(
"text-xs tabular-nums",
selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground"
- : "text-user-bubble-foreground"
+ ? selectedThreadRowColors.foregroundClassName
: (statusLabel?.className ?? "text-foreground-tertiary"),
)}
>
@@ -823,11 +805,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
@@ -848,9 +826,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
className={cn(
"flex-1 text-xs",
selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground-muted"
- : "text-user-bubble-foreground-muted"
+ ? selectedThreadRowColors.mutedForegroundClassName
: "text-danger-foreground",
)}
numberOfLines={1}
@@ -869,9 +845,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
className={cn(
"shrink text-xs",
selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground-muted"
- : "text-user-bubble-foreground-muted"
+ ? selectedThreadRowColors.mutedForegroundClassName
: "text-foreground-muted",
)}
numberOfLines={1}
@@ -881,9 +855,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
className={cn(
"text-xs",
selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground-muted"
- : "text-user-bubble-foreground-muted"
+ ? selectedThreadRowColors.mutedForegroundClassName
: "text-foreground-muted",
)}
style={{ fontFamily: MONO_FONT }}
@@ -897,9 +869,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
className={cn(
"text-xs",
selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground-muted"
- : "text-user-bubble-foreground-muted"
+ ? selectedThreadRowColors.mutedForegroundClassName
: "text-foreground-tertiary",
)}
>
@@ -913,9 +883,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
size={11}
tintColorClassName={
selected
- ? Platform.OS === "android"
- ? "accent-thread-selected-foreground-muted"
- : "accent-user-bubble-foreground-muted"
+ ? selectedThreadRowColors.mutedIconTintClassName
: "accent-foreground-tertiary"
}
/>
@@ -932,9 +900,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
size={12}
tintColorClassName={
selected
- ? Platform.OS === "android"
- ? "accent-thread-selected-foreground"
- : "accent-user-bubble-foreground"
+ ? selectedThreadRowColors.iconTintClassName
: pr.state === null || pr.isDraft
? "accent-foreground-muted"
: pr.state === "open"
@@ -949,11 +915,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
accessibilityLabel={pr.accessibilityLabel}
className={cn(
"text-xs",
- selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground"
- : "text-user-bubble-foreground"
- : pr.textClassName,
+ selected ? selectedThreadRowColors.foregroundClassName : pr.textClassName,
)}
style={{ fontFamily: MONO_FONT }}
>
@@ -968,7 +930,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
displayName={props.providerInstance.displayName}
accentColor={props.providerInstance.accentColor}
showBadge={props.providerInstance.showBadge}
- surfaceColor={providerIconSurfaceColor}
+ surfaceColor={rowAppearance.providerIconSurfaceColor}
/>
) : null}
@@ -979,14 +941,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
variant === "card" ? (
{sidebarPane ? (
cardContent
@@ -1019,10 +963,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
separates rows. The opaque screen background stays so swipe
actions reveal behind the row. */
-
- {cardContent}
-
- {Platform.OS !== "android" && props.showTrailingDivider !== false ? (
+ {cardContent}
+ {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? (
) : null}
@@ -1031,36 +973,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : (
{
close();
onSelectThread(thread);
}}
- style={
- sidebarPane || Platform.OS === "android"
- ? {
- backgroundColor: selected
- ? selectedBackgroundColor
- : sidebarPane && Platform.OS !== "android"
- ? drawerColor
- : screenColor,
- borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_V2_ROW_RADIUS,
- }
- : undefined
- }
+ style={rowAppearance.style}
>
{/* Settled history recedes: dimmed favicon + muted title. */}
@@ -1107,9 +1028,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
className={cn(
"text-sm tabular-nums",
selected
- ? Platform.OS === "android"
- ? "text-thread-selected-foreground-muted"
- : "text-user-bubble-foreground-muted"
+ ? selectedThreadRowColors.mutedForegroundClassName
: snoozedRow
? "text-foreground-secondary"
: "text-foreground-tertiary",
@@ -1131,15 +1050,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
Date: Fri, 18 Sep 2026 12:55:14 -0700
Subject: [PATCH 022/111] refactor(mobile): split settings selection rows by
platform (#12384)
---
.../threads/ThreadSettingsRows.android.tsx | 29 ++++
.../threads/ThreadSettingsRows.shared.tsx | 140 ++++++++++++++++++
.../features/threads/ThreadSettingsRows.tsx | 38 +++++
.../features/threads/ThreadSettingsSheet.tsx | 137 +----------------
4 files changed, 208 insertions(+), 136 deletions(-)
create mode 100644 apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx
create mode 100644 apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx
create mode 100644 apps/mobile/src/features/threads/ThreadSettingsRows.tsx
diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx
new file mode 100644
index 000000000000..98b414ef55d3
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadSettingsRows.android.tsx
@@ -0,0 +1,29 @@
+import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator";
+import {
+ ModelRowContent,
+ ChoiceRowContent,
+ type ModelRowProps,
+ type ChoiceRowProps,
+} from "./ThreadSettingsRows.shared";
+
+export function ModelRow(props: ModelRowProps) {
+ return (
+ }
+ />
+ );
+}
+
+export function ChoiceRow(props: ChoiceRowProps) {
+ return (
+ }
+ />
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx
new file mode 100644
index 000000000000..70a66704cdd3
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadSettingsRows.shared.tsx
@@ -0,0 +1,140 @@
+import type { ReactNode } from "react";
+import { Pressable, View } from "react-native";
+import { AppText as Text } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { cn } from "../../lib/cn";
+import type { ModelOption } from "../../lib/modelOptions";
+
+export type ModelRowProps = {
+ readonly option: ModelOption;
+ readonly selected: boolean;
+ readonly onPress: () => void;
+ readonly isFavorite: boolean;
+ readonly favoritesLoaded: boolean;
+ readonly onToggleFavorite: () => void;
+ readonly isFirst: boolean;
+ readonly isLast: boolean;
+};
+
+export type ChoiceRowProps = {
+ readonly label: string;
+ readonly description?: string;
+ readonly selected: boolean;
+ readonly onPress: () => void;
+ readonly isLast: boolean;
+};
+
+type RowSelectionProps = {
+ readonly leadingSelection?: ReactNode;
+ readonly trailingSelection?: ReactNode;
+ readonly minimumHeight?: number;
+};
+
+export function ModelRowContent(
+ props: ModelRowProps &
+ RowSelectionProps & {
+ readonly labelNumberOfLines: number;
+ readonly selectedClassName?: string;
+ },
+) {
+ return (
+
+
+ {props.leadingSelection}
+
+
+
+ {props.option.label}
+
+ {props.option.isDefault ? (
+
+ Default
+
+ ) : null}
+ {props.option.isLegacy ? (
+
+ Legacy
+
+ ) : null}
+ {props.option.isUnavailable ? (
+ Unavailable
+ ) : null}
+
+ {props.option.subtitle ? (
+
+ {props.option.subtitle}
+
+ ) : null}
+
+ {props.trailingSelection}
+
+
+
+
+
+ );
+}
+
+/** Single option inside a submenu panel. */
+export function ChoiceRowContent(props: ChoiceRowProps & RowSelectionProps) {
+ return (
+
+ {props.leadingSelection}
+
+ {props.label}
+ {props.description ? (
+ {props.description}
+ ) : null}
+
+ {props.trailingSelection}
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadSettingsRows.tsx b/apps/mobile/src/features/threads/ThreadSettingsRows.tsx
new file mode 100644
index 000000000000..016be4cb9c16
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadSettingsRows.tsx
@@ -0,0 +1,38 @@
+import { SymbolView } from "../../components/AppSymbol";
+import {
+ ModelRowContent,
+ ChoiceRowContent,
+ type ModelRowProps,
+ type ChoiceRowProps,
+} from "./ThreadSettingsRows.shared";
+
+function SelectedCheckmark(props: { readonly selected: boolean }) {
+ return props.selected ? (
+
+ ) : null;
+}
+
+export function ModelRow(props: ModelRowProps) {
+ return (
+ }
+ />
+ );
+}
+
+export function ChoiceRow(props: ChoiceRowProps) {
+ return (
+ }
+ />
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index 6c0218d453f5..9dbefe6018b7 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -40,7 +40,6 @@ import { AppText as Text } from "../../components/AppText";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { MaterialButton } from "../../components/MaterialButton";
import { MaterialIconButton } from "../../components/MaterialIconButton";
-import { MaterialRadioIndicator } from "../../components/MaterialRadioIndicator";
import { ProviderIcon } from "../../components/ProviderIcon";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
@@ -69,6 +68,7 @@ import {
NATIVE_MAIL_SEARCH_TOOLBAR_CONTENT_INSET,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
+import { ModelRow, ChoiceRow } from "./ThreadSettingsRows";
import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
import {
canCommitPendingModel,
@@ -113,101 +113,6 @@ const EMPTY_MODEL_FAVORITES: ReadonlyArray<{
readonly model: string;
}> = [];
const FAVORITES_PROVIDER_FILTER = "@favorites";
-function ModelRow(props: {
- readonly option: ModelOption;
- readonly selected: boolean;
- readonly onPress: () => void;
- readonly isFavorite: boolean;
- readonly favoritesLoaded: boolean;
- readonly onToggleFavorite: () => void;
- readonly isFirst: boolean;
- readonly isLast: boolean;
-}) {
- const selectedMaterialRow = Platform.OS === "android" && props.selected;
- return (
-
-
- {Platform.OS === "android" ? : null}
-
-
-
- {props.option.label}
-
- {props.option.isDefault ? (
-
- Default
-
- ) : null}
- {props.option.isLegacy ? (
-
- Legacy
-
- ) : null}
- {props.option.isUnavailable ? (
- Unavailable
- ) : null}
-
- {props.option.subtitle ? (
-
- {props.option.subtitle}
-
- ) : null}
-
- {props.selected && Platform.OS !== "android" ? (
-
- ) : null}
-
-
-
-
-
- );
-}
-
/** Provider catalog header with its harness logo and disclosure state. */
function ProviderHeader(props: {
readonly driver: string | undefined;
@@ -296,46 +201,6 @@ function DisclosureRow(props: {
);
}
-/** Single option inside a submenu panel. */
-function ChoiceRow(props: {
- readonly label: string;
- readonly description?: string;
- readonly selected: boolean;
- readonly onPress: () => void;
- readonly isLast: boolean;
-}) {
- return (
-
- {Platform.OS === "android" ? : null}
-
- {props.label}
- {props.description ? (
- {props.description}
- ) : null}
-
- {props.selected && Platform.OS !== "android" ? (
-
- ) : null}
-
- );
-}
-
function SwitchRow(props: {
readonly label: string;
readonly value: boolean;
From 4fb9dfc8b8600d830b36da1c15c5f0cd21627e77 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:14 -0700
Subject: [PATCH 023/111] refactor(mobile): centralize platform header
rendering (#12388)
---
.../src/components/ScreenHeader.android.tsx | 203 ++++++++++++
apps/mobile/src/components/ScreenHeader.tsx | 164 ++++++++++
.../src/components/ScreenHeader.types.ts | 76 +++++
.../components/ScreenHeaderButton.android.tsx | 1 +
.../src/components/ScreenHeaderButton.tsx | 4 +
.../src/components/headerMenu.android.ts | 43 +++
apps/mobile/src/components/headerMenu.test.ts | 66 ++++
.../src/components/nativeHeaderMenu.ios.tsx | 44 +++
.../connection/ConnectionsNewRouteScreen.tsx | 24 +-
.../SettingsEnvironmentsRouteScreen.tsx | 18 +-
.../settings/components/SettingsScreen.tsx | 30 +-
.../threads/ThreadGitControls.android.tsx | 30 ++
.../features/threads/ThreadGitControls.tsx | 95 +++---
.../native/NativeHeaderToolbar.android.tsx | 43 +++
.../mobile/src/native/NativeHeaderToolbar.tsx | 294 ++++++++++++++++++
apps/mobile/src/native/StackHeader.tsx | 289 +----------------
16 files changed, 1042 insertions(+), 382 deletions(-)
create mode 100644 apps/mobile/src/components/ScreenHeader.android.tsx
create mode 100644 apps/mobile/src/components/ScreenHeader.tsx
create mode 100644 apps/mobile/src/components/ScreenHeader.types.ts
create mode 100644 apps/mobile/src/components/ScreenHeaderButton.android.tsx
create mode 100644 apps/mobile/src/components/ScreenHeaderButton.tsx
create mode 100644 apps/mobile/src/components/headerMenu.android.ts
create mode 100644 apps/mobile/src/components/headerMenu.test.ts
create mode 100644 apps/mobile/src/components/nativeHeaderMenu.ios.tsx
create mode 100644 apps/mobile/src/features/threads/ThreadGitControls.android.tsx
create mode 100644 apps/mobile/src/native/NativeHeaderToolbar.android.tsx
create mode 100644 apps/mobile/src/native/NativeHeaderToolbar.tsx
diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx
new file mode 100644
index 000000000000..edb0b770e93c
--- /dev/null
+++ b/apps/mobile/src/components/ScreenHeader.android.tsx
@@ -0,0 +1,203 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { NativeStackScreenOptions } from "../native/StackHeader";
+import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { AndroidScreenHeader } from "./AndroidScreenHeader";
+import { ScreenHeaderButton } from "./ScreenHeaderButton.android";
+import { SymbolView } from "./AppSymbol";
+import { ControlPillMenu } from "./ControlPill";
+import { MaterialSearchField } from "./MaterialSearchField";
+import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
+import type { ScreenHeaderProps } from "./ScreenHeader.types";
+
+export function ScreenHeader(props: ScreenHeaderProps) {
+ const { search } = props;
+ const insets = useSafeAreaInsets();
+ const { themeVariables } = useAppearancePreferences();
+ const inputRef = useRef(null);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const searching = search !== undefined && (searchOpen || search.value.length > 0);
+ const onSearchChange = search?.onChangeText;
+ const closeSearch = useCallback(() => {
+ onSearchChange?.("");
+ setSearchOpen(false);
+ Keyboard.dismiss();
+ }, [onSearchChange]);
+ useEffect(() => {
+ if (!searching || search?.mode === "inline") return;
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
+ closeSearch();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [closeSearch, searching, search?.mode]);
+ const menuView = props.menus?.map((menu) => (
+ {
+ const action = findHeaderMenuAction(menu.items, nativeEvent.event);
+ if (action && !action.disabled) action.onPress();
+ }}
+ >
+ {search?.mode === "inline" ? (
+
+
+
+ ) : (
+
+ )}
+
+ ));
+ const options = (
+
+ );
+ if (search?.mode === "inline") {
+ return (
+ <>
+ {options}
+
+
+ {props.onBack ? (
+
+
+
+ ) : null}
+
+
+
+
+ {menuView}
+
+
+ >
+ );
+ }
+ const header = (
+ : undefined}
+ hideBottomBorder={props.hideBottomBorder}
+ actions={
+ search
+ ? [
+ {
+ accessibilityLabel: search.placeholder,
+ icon: "magnifyingglass",
+ onPress: () => setSearchOpen(true),
+ },
+ ...(search.onRefresh
+ ? [
+ {
+ accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh",
+ icon: "arrow.clockwise" as const,
+ onPress: search.onRefresh,
+ },
+ ]
+ : []),
+ ...(props.actions ?? []),
+ ]
+ : props.actions
+ }
+ trailing={
+ <>
+ {menuView}
+ {props.trailing}
+ >
+ }
+ />
+ );
+ return (
+ <>
+ {options}
+ {search ? (
+
+
+ {header}
+
+ {searching ? (
+
+
+
+
+
+
+ ) : null}
+
+ ) : (
+ header
+ )}
+ >
+ );
+}
diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx
new file mode 100644
index 000000000000..b5e5d1a5dd12
--- /dev/null
+++ b/apps/mobile/src/components/ScreenHeader.tsx
@@ -0,0 +1,164 @@
+import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
+import { useId } from "react";
+import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
+import { ScreenHeaderButton } from "./ScreenHeaderButton";
+import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
+import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
+import {
+ createNativeMailSearchToolbarItem,
+ NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
+} from "../features/layout/native-mail-search-toolbar";
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";
+import type { AppSymbolName } from "./AppSymbol";
+
+function iosIcon(icon: AppSymbolName) {
+ return typeof icon === "string" ? icon : icon.ios;
+}
+
+type MailMenu = NonNullable;
+function mailMenuItems(items: ReadonlyArray): MailMenu["items"] {
+ return items.map((item) =>
+ "items" in item
+ ? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) }
+ : {
+ type: "action",
+ title: item.title,
+ state: item.selected ? "on" : "off",
+ disabled: item.disabled,
+ onPress: item.onPress,
+ },
+ );
+}
+
+export function ScreenHeader(props: ScreenHeaderProps) {
+ const headerId = useId();
+ const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
+ const { themeVariables } = useAppearancePreferences();
+ const { search, menus } = props;
+ const menu = menus?.[0];
+ const compactSearch =
+ search !== undefined &&
+ (search.compactToolbar ?? !layout.usesSplitView) &&
+ NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
+ const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
+ const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;
+ return (
+ <>
+ [
+ createNativeMailSearchToolbarItem({
+ showsSearchDismissButton: true,
+ placeholder: search.compactPlaceholder ?? search.placeholder,
+ onSearchTextChange: search.onChangeText,
+ searchTextChangeId: `${headerId}-search-text`,
+ ...(refresh
+ ? {
+ composeButtonId: `${headerId}-refresh`,
+ composeSystemImageName: "arrow.clockwise",
+ onComposePress: refresh,
+ }
+ : undefined),
+ ...(menu
+ ? {
+ filterButtonId: `${headerId}-filter`,
+ filterSystemImageName: iosIcon(menu.icon),
+ filterMenu: { title: menu.title, items: mailMenuItems(menu.items) },
+ }
+ : undefined),
+ }),
+ ]
+ : undefined,
+ headerSearchBarOptions: compactSearch
+ ? undefined
+ : {
+ allowToolbarIntegration: true,
+ ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline"
+ ? { placement: "integratedButton" as const }
+ : undefined),
+ autoCapitalize: "none",
+ hideNavigationBar: false,
+ ...(search.mode === "inline" ? { obscureBackground: false } : undefined),
+ placeholder: search.placeholder,
+ onChangeText: (event) => search.onChangeText(event.nativeEvent.text),
+ onCancelButtonPress: () => search.onChangeText(""),
+ },
+ }
+ : undefined),
+ ...props.options,
+ }}
+ />
+ {props.sidebar !== false && layout.usesSplitView ? (
+
+ {props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? (
+
+ ) : null}
+
+
+ ) : null}
+ {(props.actions?.length ||
+ (!compactSearch && refresh) ||
+ visibleMenus.length ||
+ props.trailing) &&
+ props.options?.unstable_headerRightItems === undefined ? (
+
+ {props.actions?.map((action) => (
+
+ ))}
+ {refresh && !compactSearch ? (
+
+ ) : null}
+ {visibleMenus.map(createNativeHeaderMenu)}
+ {props.trailing}
+
+ ) : null}
+ >
+ );
+}
+
+export type {
+ ScreenHeaderAction,
+ ScreenHeaderMenu,
+ ScreenHeaderMenuItem,
+ ScreenHeaderProps,
+ ScreenHeaderSearch,
+} from "./ScreenHeader.types";
diff --git a/apps/mobile/src/components/ScreenHeader.types.ts b/apps/mobile/src/components/ScreenHeader.types.ts
new file mode 100644
index 000000000000..3373181a3566
--- /dev/null
+++ b/apps/mobile/src/components/ScreenHeader.types.ts
@@ -0,0 +1,76 @@
+import type { ReactNode } from "react";
+import type { ColorValue } from "react-native";
+import type { AppNativeStackNavigationOptions } from "../native/StackHeader";
+import type { AppSymbolName } from "./AppSymbol";
+
+export interface ScreenHeaderAction {
+ readonly accessibilityLabel: string;
+ readonly icon: AppSymbolName;
+ readonly onPress: () => void;
+ readonly disabled?: boolean;
+ readonly selected?: boolean;
+ readonly tintColor?: ColorValue;
+}
+
+export type ScreenHeaderMenuItem =
+ | {
+ readonly id: string;
+ readonly title: string;
+ readonly icon?: string;
+ readonly subtitle?: string;
+ readonly disabled?: boolean;
+ readonly selected?: boolean;
+ readonly onPress: () => void;
+ }
+ | {
+ readonly id: string;
+ readonly title?: string;
+ readonly icon?: string;
+ readonly inline?: boolean;
+ readonly items: ReadonlyArray;
+ };
+
+export interface ScreenHeaderMenu {
+ readonly title: string;
+ readonly icon: AppSymbolName;
+ readonly status?: string;
+ readonly separateBackground?: boolean;
+ readonly items: ReadonlyArray;
+}
+
+export interface ScreenHeaderSearch {
+ readonly value: string;
+ readonly onChangeText: (value: string) => void;
+ readonly placeholder: string;
+ readonly mode?: "inline" | "collapsible";
+ readonly compactToolbar?: boolean;
+ readonly compactPlaceholder?: string;
+ readonly closeAccessibilityLabel?: string;
+ readonly clearAccessibilityLabel?: string;
+ readonly onRefresh?: () => void;
+ readonly refreshAccessibilityLabel?: string;
+ readonly refreshInToolbar?: boolean;
+}
+
+export interface ScreenHeaderProps {
+ readonly title: string;
+ readonly subtitle?: string;
+ readonly actions?: ReadonlyArray;
+ readonly menus?: ReadonlyArray;
+ readonly search?: ScreenHeaderSearch;
+ readonly onBack?: () => void;
+ readonly sidebar?: boolean;
+ readonly backInSplitView?: {
+ readonly accessibilityLabel: string;
+ readonly icon: string;
+ readonly separateBackground?: boolean;
+ readonly onPress?: () => void;
+ };
+ readonly hideBottomBorder?: boolean;
+ readonly trailing?: ReactNode;
+ /** Search scenes use sheet colors on iOS and header colors on Android. */
+ readonly matchSearchSurface?: boolean;
+ /** Custom native layouts can override options without duplicating the header renderer. */
+ readonly options?: AppNativeStackNavigationOptions;
+ readonly optionsVersion?: unknown;
+}
diff --git a/apps/mobile/src/components/ScreenHeaderButton.android.tsx b/apps/mobile/src/components/ScreenHeaderButton.android.tsx
new file mode 100644
index 000000000000..905997a3da6e
--- /dev/null
+++ b/apps/mobile/src/components/ScreenHeaderButton.android.tsx
@@ -0,0 +1 @@
+export { AndroidHeaderIconButton as ScreenHeaderButton } from "./AndroidScreenHeader";
diff --git a/apps/mobile/src/components/ScreenHeaderButton.tsx b/apps/mobile/src/components/ScreenHeaderButton.tsx
new file mode 100644
index 000000000000..929a2e9f62e2
--- /dev/null
+++ b/apps/mobile/src/components/ScreenHeaderButton.tsx
@@ -0,0 +1,4 @@
+import { NativeHeaderToolbar } from "../native/StackHeader";
+export const ScreenHeaderButton: (
+ props: Parameters[0] & { readonly selected?: boolean },
+) => ReturnType = NativeHeaderToolbar.Button;
diff --git a/apps/mobile/src/components/headerMenu.android.ts b/apps/mobile/src/components/headerMenu.android.ts
new file mode 100644
index 000000000000..49c9d9abeb6a
--- /dev/null
+++ b/apps/mobile/src/components/headerMenu.android.ts
@@ -0,0 +1,43 @@
+import type { MenuAction } from "@react-native-menu/menu";
+import type { ScreenHeaderMenuItem } from "./ScreenHeader.types";
+
+export function androidHeaderMenuActions(items: ReadonlyArray): MenuAction[] {
+ return items.flatMap((item): MenuAction[] =>
+ "items" in item
+ ? item.inline && !item.title
+ ? androidHeaderMenuActions(item.items)
+ : [
+ {
+ id: item.id,
+ title: item.title ?? "",
+ image: item.icon,
+ subactions: androidHeaderMenuActions(item.items),
+ },
+ ]
+ : [
+ {
+ id: item.id,
+ title: item.title,
+ subtitle: item.subtitle,
+ image: item.icon,
+ state: item.selected ? "on" : undefined,
+ attributes: item.disabled ? { disabled: true } : undefined,
+ },
+ ],
+ );
+}
+
+export function findHeaderMenuAction(
+ items: ReadonlyArray,
+ id: string,
+): Extract void }> | undefined {
+ for (const item of items) {
+ if ("items" in item) {
+ const action = findHeaderMenuAction(item.items, id);
+ if (action) return action;
+ } else if (item.id === id) {
+ return item;
+ }
+ }
+ return undefined;
+}
diff --git a/apps/mobile/src/components/headerMenu.test.ts b/apps/mobile/src/components/headerMenu.test.ts
new file mode 100644
index 000000000000..bbc0b1d1ee4d
--- /dev/null
+++ b/apps/mobile/src/components/headerMenu.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vite-plus/test";
+import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
+import type { ScreenHeaderMenuItem } from "./ScreenHeader.types";
+
+const items: ReadonlyArray = [
+ {
+ id: "sessions",
+ title: "Sessions",
+ items: [
+ {
+ id: "terminal-session:main",
+ title: "Main shell",
+ subtitle: "Running · workspace",
+ selected: true,
+ onPress: () => {},
+ },
+ { id: "closed", title: "Closed shell", disabled: true, onPress: () => {} },
+ ],
+ },
+];
+
+describe("header menus", () => {
+ it("keeps named sections as Android submenus", () => {
+ const actions = androidHeaderMenuActions([
+ {
+ id: "text-size",
+ title: "Text size",
+ inline: true,
+ items: [{ id: "font-increase", title: "A+", onPress: () => {} }],
+ },
+ ]);
+ expect(actions[0]?.title).toBe("Text size");
+ expect(actions[0]?.subactions?.[0]?.id).toBe("font-increase");
+ });
+
+ it("flattens inline groups while keeping their action IDs", () => {
+ const actions = androidHeaderMenuActions([
+ { id: "modes", inline: true, items: [{ id: "code", title: "Code", onPress: () => {} }] },
+ ]);
+ expect(actions.map(({ id }) => id)).toEqual(["code"]);
+ });
+
+ it("keeps stable action IDs, selection, and disabled state inside submenus", () => {
+ const menu = androidHeaderMenuActions(items);
+ expect(menu[0]?.id).toBe("sessions");
+ expect(menu[0]?.subactions?.[0]).toMatchObject({
+ id: "terminal-session:main",
+ title: "Main shell",
+ subtitle: "Running · workspace",
+ state: "on",
+ });
+ expect(menu[0]?.subactions?.[1]).toMatchObject({
+ id: "closed",
+ attributes: { disabled: true },
+ });
+ });
+
+ it("resolves nested action IDs without dispatching group or missing IDs", () => {
+ expect(findHeaderMenuAction(items, "terminal-session:main")).toMatchObject({
+ title: "Main shell",
+ selected: true,
+ });
+ expect(findHeaderMenuAction(items, "sessions")).toBeUndefined();
+ expect(findHeaderMenuAction(items, "removed-session")).toBeUndefined();
+ });
+});
diff --git a/apps/mobile/src/components/nativeHeaderMenu.ios.tsx b/apps/mobile/src/components/nativeHeaderMenu.ios.tsx
new file mode 100644
index 000000000000..b02d10b6b697
--- /dev/null
+++ b/apps/mobile/src/components/nativeHeaderMenu.ios.tsx
@@ -0,0 +1,44 @@
+import { NativeHeaderToolbar } from "../native/StackHeader";
+import type { ScreenHeaderMenu, ScreenHeaderMenuItem } from "./ScreenHeader.types";
+
+function renderMenuItems(items: ReadonlyArray) {
+ return items.map((item) =>
+ "items" in item ? (
+
+ {renderMenuItems(item.items)}
+
+ ) : (
+
+ {item.title}
+
+ ),
+ );
+}
+
+/** Returns direct native items for toolbar serialization, including nested menu data. */
+export function createNativeHeaderMenu(menu: ScreenHeaderMenu) {
+ return (
+
+ {menu.status ? {menu.status} : null}
+ {renderMenuItems(menu.items)}
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
index 6c0d60beb5f5..5c58dfc1eaf9 100644
--- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
+++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx
@@ -1,6 +1,5 @@
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { CameraView, useCameraPermissions } from "expo-camera";
-import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import {
StackActions,
useNavigation,
@@ -194,7 +193,8 @@ export function ConnectionsNewRouteScreen({
actions={[
{
accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code",
- icon: showScanner ? "xmark" : "camera",
+ icon: showScanner ? "xmark" : Platform.OS === "ios" ? "qrcode.viewfinder" : "camera",
+ tintColor: headerIconColor,
onPress: () => {
if (showScanner) {
closeScanner();
@@ -205,26 +205,6 @@ export function ConnectionsNewRouteScreen({
},
]}
>
-
- {Platform.OS !== "android" ? (
-
- {
- if (showScanner) {
- closeScanner();
- } else {
- void openScanner();
- }
- }}
- separateBackground
- tintColor={headerIconColor}
- />
-
- ) : null}
-
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
@@ -90,21 +89,6 @@ export function SettingsEnvironmentsRouteScreen() {
},
]}
>
- {Platform.OS !== "android" ? (
-
-
- navigation.navigate("SettingsSheet", {
- screen: "SettingsContent",
- params: { screen: "SettingsEnvironmentNew" },
- })
- }
- separateBackground
- tintColor={headerIconColor}
- />
-
- ) : null}
, "title" | "actions" | "trailing"> & {
+ props: Pick & {
readonly children: ReactNode;
/** A native form sheet already owns its rounded outer frame. */
readonly formSheet?: boolean;
@@ -20,19 +19,14 @@ export function SettingsScreen(
return (
- {Platform.OS === "android" ? (
- <>
-
- }
- actions={props.actions}
- trailing={props.trailing}
- onBack={() => navigation.goBack()}
- hideBottomBorder={!props.formSheet}
- />
- >
- ) : null}
+ navigation.goBack()}
+ hideBottomBorder={!props.formSheet}
+ />
{props.formSheet ? (
props.children
) : (
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.android.tsx b/apps/mobile/src/features/threads/ThreadGitControls.android.tsx
new file mode 100644
index 000000000000..718513201cd3
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadGitControls.android.tsx
@@ -0,0 +1,30 @@
+import type {
+ ThreadGitControls as IosThreadGitControls,
+ ThreadGitMenu as IosThreadGitMenu,
+ useThreadGitRightHeaderItems as useIosThreadGitRightHeaderItems,
+} from "./ThreadGitControls";
+
+export type { ThreadGitMenuProps } from "./ThreadGitControls";
+
+type ThreadGitControlsProps = Parameters[0];
+const EMPTY_HEADER_ITEMS: ReturnType = [];
+
+export function useThreadGitRightHeaderItems(_props: ThreadGitControlsProps) {
+ return EMPTY_HEADER_ITEMS;
+}
+
+export function useThreadGitCenterHeaderItems(_props: ThreadGitControlsProps) {
+ return EMPTY_HEADER_ITEMS;
+}
+
+export function ThreadGitControls(_props: ThreadGitControlsProps) {
+ return null;
+}
+
+export function ThreadGitMenu(_props: Parameters[0]) {
+ return null;
+}
+
+export function useThreadGitMenuDefinition(_props: Parameters[0]) {
+ return null;
+}
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index 31b65f49353a..e3a4033cd809 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -1,3 +1,5 @@
+import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios";
+import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types";
import {
EnvironmentId,
type GitRunStackedActionResult,
@@ -426,6 +428,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
) : null}
{showActionControls ? (
) : null}
- {showActionControls ? : null}
+ {showActionControls ? createNativeHeaderMenu(threadGitMenuDefinition(props, model)) : null}
);
}
@@ -500,43 +503,57 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
* chat header and the review screen's toolbar.
*/
export function ThreadGitMenu(props: ThreadGitMenuProps) {
- const model = useThreadGitControlModel(props);
+ const menu = useThreadGitMenuDefinition(props);
+ return menu ? createNativeHeaderMenu(menu) : null;
+}
- return (
-
- {}}
- subtitle={compactMenuStatus(props.gitStatus)}
- >
-
- {compactMenuBranchLabel(model.currentBranchLabel)}
-
-
- void model.runQuickAction()}
- subtitle={model.quickActionHint ?? undefined}
- >
- {model.quickAction.label}
-
-
- Review changes
-
-
- More
-
-
- );
+/** Returns menu data because native toolbars serialize direct items rather than rendering component children. */
+export function useThreadGitMenuDefinition(props: ThreadGitMenuProps): ScreenHeaderMenu | null {
+ return threadGitMenuDefinition(props, useThreadGitControlModel(props));
+}
+
+function threadGitMenuDefinition(
+ props: ThreadGitMenuProps,
+ model: ReturnType,
+): ScreenHeaderMenu {
+ return {
+ title: "Git controls",
+ icon: "point.topleft.down.curvedto.point.bottomright.up",
+ separateBackground: false,
+ items: [
+ {
+ id: "git-status",
+ title: compactMenuBranchLabel(model.currentBranchLabel),
+ icon: "point.topleft.down.curvedto.point.bottomright.up",
+ disabled: true,
+ subtitle: compactMenuStatus(props.gitStatus),
+ onPress: () => {},
+ },
+ {
+ id: "git-quick-action",
+ title: model.quickAction.label,
+ icon: model.quickActionIcon,
+ disabled: model.quickAction.disabled,
+ subtitle: model.quickActionHint ?? undefined,
+ onPress: () => {
+ void model.runQuickAction();
+ },
+ },
+ {
+ id: "git-review",
+ title: "Review changes",
+ icon: "text.bubble",
+ disabled: !model.isRepo,
+ subtitle: "Turn diffs and worktree changes",
+ onPress: model.openReview,
+ },
+ {
+ id: "git-more",
+ title: "More",
+ icon: "ellipsis",
+ subtitle: "Commit, files, branches",
+ onPress: model.openGitInspector,
+ },
+ ],
+ };
}
diff --git a/apps/mobile/src/native/NativeHeaderToolbar.android.tsx b/apps/mobile/src/native/NativeHeaderToolbar.android.tsx
new file mode 100644
index 000000000000..efa7b7e6813e
--- /dev/null
+++ b/apps/mobile/src/native/NativeHeaderToolbar.android.tsx
@@ -0,0 +1,43 @@
+import type { NativeHeaderToolbar as IosNativeHeaderToolbar } from "./NativeHeaderToolbar";
+
+function NativeHeaderToolbarRoot(_props: Parameters[0]) {
+ return null;
+}
+
+function NativeHeaderToolbarButton(_props: Parameters[0]) {
+ return null;
+}
+
+function NativeHeaderToolbarMenu(_props: Parameters[0]) {
+ return null;
+}
+
+function NativeHeaderToolbarMenuAction(
+ _props: Parameters[0],
+) {
+ return null;
+}
+
+function NativeHeaderToolbarLabel(_props: Parameters[0]) {
+ return null;
+}
+
+function NativeHeaderToolbarSpacer(_props: Parameters[0]) {
+ return null;
+}
+
+function NativeHeaderToolbarSearchBarSlot() {
+ return null;
+}
+
+// Native header item factories are iOS-only; Android owns its in-flow header.
+export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
+ Button: NativeHeaderToolbarButton,
+ Label: NativeHeaderToolbarLabel,
+ Menu: Object.assign(NativeHeaderToolbarMenu, {
+ Action: NativeHeaderToolbarMenuAction,
+ }),
+ MenuAction: NativeHeaderToolbarMenuAction,
+ SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
+ Spacer: NativeHeaderToolbarSpacer,
+});
diff --git a/apps/mobile/src/native/NativeHeaderToolbar.tsx b/apps/mobile/src/native/NativeHeaderToolbar.tsx
new file mode 100644
index 000000000000..3bab99546bb5
--- /dev/null
+++ b/apps/mobile/src/native/NativeHeaderToolbar.tsx
@@ -0,0 +1,294 @@
+import { useNavigation, type ParamListBase } from "@react-navigation/native";
+import type {
+ NativeStackHeaderItem,
+ NativeStackHeaderItemMenu,
+ NativeStackNavigationOptions,
+ NativeStackNavigationProp,
+} from "@react-navigation/native-stack";
+import {
+ Children,
+ isValidElement,
+ useLayoutEffect,
+ useMemo,
+ type ReactElement,
+ type ReactNode,
+} from "react";
+import type { ColorValue } from "react-native";
+
+function useNativeStackNavigation(): NativeStackNavigationProp | null {
+ return useNavigation>();
+}
+
+function labelFromChildren(children: ReactNode): string {
+ const parts: string[] = [];
+ Children.forEach(children, (child) => {
+ if (typeof child === "string" || typeof child === "number") {
+ parts.push(String(child));
+ } else if (isValidElement<{ children?: ReactNode }>(child)) {
+ parts.push(labelFromChildren(child.props.children));
+ }
+ });
+ return parts.join("");
+}
+
+type NativeStackHeaderIcon = NonNullable<
+ Extract["icon"]
+>;
+type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
+ unstable_headerToolbarItems?: () => NativeStackHeaderItem[];
+};
+
+function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
+ if (typeof icon !== "string") {
+ return undefined;
+ }
+ return { type: "sfSymbol", name: icon as never };
+}
+
+type ToolbarElementProps = Record & { readonly children?: ReactNode };
+
+function elementTypeName(element: ReactElement): string | undefined {
+ const type = element.type;
+ if (typeof type === "function") {
+ return (type as { displayName?: string; name?: string }).displayName ?? type.name;
+ }
+ return undefined;
+}
+
+function convertMenuAction(
+ element: ReactElement,
+): NativeStackHeaderItemMenu["menu"]["items"][number] | null {
+ const typeName = elementTypeName(element);
+ if (typeName === "NativeHeaderToolbarMenuAction") {
+ const label = labelFromChildren(element.props.children);
+ return {
+ type: "action",
+ label,
+ description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined,
+ disabled: Boolean(element.props.disabled),
+ icon: iconFromProp(element.props.icon),
+ onPress:
+ typeof element.props.onPress === "function"
+ ? (element.props.onPress as () => void)
+ : () => undefined,
+ state: element.props.isOn === true ? "on" : undefined,
+ destructive: Boolean(element.props.destructive),
+ discoverabilityLabel:
+ typeof element.props.discoverabilityLabel === "string"
+ ? element.props.discoverabilityLabel
+ : undefined,
+ };
+ }
+
+ if (typeName === "NativeHeaderToolbarMenu") {
+ return {
+ type: "submenu",
+ label:
+ typeof element.props.title === "string"
+ ? element.props.title
+ : labelFromChildren(element.props.children),
+ icon: iconFromProp(element.props.icon),
+ inline: Boolean(element.props.inline),
+ items: collectMenuItems(element.props.children),
+ };
+ }
+
+ return null;
+}
+
+function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] {
+ const items: NativeStackHeaderItemMenu["menu"]["items"] = [];
+ Children.forEach(children, (child) => {
+ if (!isValidElement(child)) {
+ return;
+ }
+ const item = convertMenuAction(child);
+ if (item) {
+ items.push(item);
+ return;
+ }
+ items.push(...collectMenuItems(child.props.children));
+ });
+ return items;
+}
+
+function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
+ if (!isValidElement(child)) {
+ return null;
+ }
+
+ const typeName = elementTypeName(child);
+ if (typeName === "NativeHeaderToolbarButton") {
+ return {
+ type: "button",
+ label: typeof child.props.label === "string" ? child.props.label : "",
+ accessibilityLabel:
+ typeof child.props.accessibilityLabel === "string"
+ ? child.props.accessibilityLabel
+ : undefined,
+ disabled: Boolean(child.props.disabled),
+ icon: iconFromProp(child.props.icon),
+ onPress:
+ typeof child.props.onPress === "function"
+ ? (child.props.onPress as () => void)
+ : () => undefined,
+ sharesBackground: !child.props.separateBackground,
+ tintColor: child.props.tintColor as ColorValue | undefined,
+ variant: "plain",
+ };
+ }
+
+ if (typeName === "NativeHeaderToolbarMenu") {
+ return {
+ type: "menu",
+ label: typeof child.props.title === "string" ? child.props.title : "",
+ accessibilityLabel:
+ typeof child.props.accessibilityLabel === "string"
+ ? child.props.accessibilityLabel
+ : undefined,
+ disabled: Boolean(child.props.disabled),
+ icon: iconFromProp(child.props.icon),
+ menu: {
+ title: typeof child.props.title === "string" ? child.props.title : undefined,
+ items: collectMenuItems(child.props.children),
+ },
+ sharesBackground: !child.props.separateBackground,
+ tintColor: child.props.tintColor as ColorValue | undefined,
+ variant: "plain",
+ };
+ }
+
+ if (typeName === "NativeHeaderToolbarSpacer") {
+ return {
+ type: "spacing",
+ spacing: typeof child.props.width === "number" ? child.props.width : 8,
+ flexible: Boolean(child.props.flexible),
+ } as NativeStackHeaderItem;
+ }
+
+ return null;
+}
+
+function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
+ const items: NativeStackHeaderItem[] = [];
+ Children.forEach(children, (child) => {
+ const item = convertToolbarChild(child);
+ if (item) {
+ if (item.type === "spacing") {
+ // Native inserts spacing items at `index`, treating a missing index
+ // as 0 — which would move the spacer in front of earlier siblings.
+ (item as { index?: number }).index = items.length;
+ }
+ items.push(item);
+ }
+ });
+ return items;
+}
+
+function NativeHeaderToolbarRoot(props: {
+ readonly placement?: "left" | "right" | "bottom";
+ readonly children?: ReactNode;
+}) {
+ const navigation = useNativeStackNavigation();
+ const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
+
+ // Swap toolbar owners before paint so split and compact headers cannot clear each other.
+ useLayoutEffect(() => {
+ if (!navigation) {
+ return;
+ }
+ if (props.placement === "bottom") {
+ navigation.setOptions({
+ unstable_headerToolbarItems: () => items,
+ } as NativeStackOptionsWithToolbar);
+ return () => {
+ navigation.setOptions({
+ unstable_headerToolbarItems: () => [],
+ } as NativeStackOptionsWithToolbar);
+ };
+ }
+ if (props.placement === "left") {
+ navigation.setOptions({ unstable_headerLeftItems: () => items });
+ return () => {
+ navigation.setOptions({ unstable_headerLeftItems: () => [] });
+ };
+ }
+ navigation.setOptions({ unstable_headerRightItems: () => items });
+ return () => {
+ navigation.setOptions({ unstable_headerRightItems: () => [] });
+ };
+ }, [items, navigation, props.placement]);
+
+ return null;
+}
+
+function NativeHeaderToolbarButton(_props: {
+ readonly accessibilityLabel?: string;
+ readonly disabled?: boolean;
+ readonly icon?: string;
+ readonly label?: string;
+ readonly onPress?: () => void;
+ readonly separateBackground?: boolean;
+ readonly tintColor?: ColorValue;
+}) {
+ return null;
+}
+NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton";
+
+function NativeHeaderToolbarMenu(_props: {
+ readonly accessibilityLabel?: string;
+ readonly children?: ReactNode;
+ readonly disabled?: boolean;
+ readonly icon?: string;
+ readonly inline?: boolean;
+ readonly separateBackground?: boolean;
+ readonly tintColor?: ColorValue;
+ readonly title?: string;
+}) {
+ return null;
+}
+NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu";
+
+function NativeHeaderToolbarMenuAction(_props: {
+ readonly children?: ReactNode;
+ readonly destructive?: boolean;
+ readonly disabled?: boolean;
+ readonly discoverabilityLabel?: string;
+ readonly icon?: string;
+ readonly isOn?: boolean;
+ readonly onPress?: () => void;
+ readonly subtitle?: string;
+}) {
+ return null;
+}
+NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
+
+function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
+ return null;
+}
+NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
+
+function NativeHeaderToolbarSpacer(_props: {
+ readonly flexible?: boolean;
+ readonly sharesBackground?: boolean;
+ readonly width?: number;
+}) {
+ return null;
+}
+NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer";
+
+function NativeHeaderToolbarSearchBarSlot() {
+ return null;
+}
+NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot";
+
+export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
+ Button: NativeHeaderToolbarButton,
+ Label: NativeHeaderToolbarLabel,
+ Menu: Object.assign(NativeHeaderToolbarMenu, {
+ Action: NativeHeaderToolbarMenuAction,
+ }),
+ MenuAction: NativeHeaderToolbarMenuAction,
+ SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
+ Spacer: NativeHeaderToolbarSpacer,
+});
diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx
index a524d5152473..4f8eadcc5a11 100644
--- a/apps/mobile/src/native/StackHeader.tsx
+++ b/apps/mobile/src/native/StackHeader.tsx
@@ -1,22 +1,13 @@
import { useNavigation, type ParamListBase } from "@react-navigation/native";
import type {
- NativeStackHeaderItem,
- NativeStackHeaderItemMenu,
NativeStackNavigationOptions,
NativeStackNavigationProp,
} from "@react-navigation/native-stack";
-import {
- Children,
- isValidElement,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- type ReactElement,
- type ReactNode,
-} from "react";
+import { useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { ColorValue } from "react-native";
+export { NativeHeaderToolbar } from "./NativeHeaderToolbar";
+
export {
nativeHeaderScrollEdgeEffects,
nativeTopScrollEdgeEffect,
@@ -196,277 +187,3 @@ export function NativeStackScreenOptions(props: {
return null;
}
-
-function labelFromChildren(children: ReactNode): string {
- const parts: string[] = [];
- Children.forEach(children, (child) => {
- if (typeof child === "string" || typeof child === "number") {
- parts.push(String(child));
- } else if (isValidElement<{ children?: ReactNode }>(child)) {
- parts.push(labelFromChildren(child.props.children));
- }
- });
- return parts.join("");
-}
-
-type NativeStackHeaderIcon = NonNullable<
- Extract["icon"]
->;
-type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & {
- unstable_headerToolbarItems?: () => NativeStackHeaderItem[];
-};
-
-function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined {
- if (typeof icon !== "string") {
- return undefined;
- }
- return { type: "sfSymbol", name: icon as never };
-}
-
-type ToolbarElementProps = Record & { readonly children?: ReactNode };
-
-function elementTypeName(element: ReactElement): string | undefined {
- const type = element.type;
- if (typeof type === "function") {
- return (type as { displayName?: string; name?: string }).displayName ?? type.name;
- }
- return undefined;
-}
-
-function convertMenuAction(
- element: ReactElement,
-): NativeStackHeaderItemMenu["menu"]["items"][number] | null {
- const typeName = elementTypeName(element);
- if (typeName === "NativeHeaderToolbarMenuAction") {
- const label = labelFromChildren(element.props.children);
- return {
- type: "action",
- label,
- description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined,
- disabled: Boolean(element.props.disabled),
- icon: iconFromProp(element.props.icon),
- onPress:
- typeof element.props.onPress === "function"
- ? (element.props.onPress as () => void)
- : () => undefined,
- state: element.props.isOn === true ? "on" : undefined,
- destructive: Boolean(element.props.destructive),
- discoverabilityLabel:
- typeof element.props.discoverabilityLabel === "string"
- ? element.props.discoverabilityLabel
- : undefined,
- };
- }
-
- if (typeName === "NativeHeaderToolbarMenu") {
- return {
- type: "submenu",
- label:
- typeof element.props.title === "string"
- ? element.props.title
- : labelFromChildren(element.props.children),
- icon: iconFromProp(element.props.icon),
- inline: Boolean(element.props.inline),
- items: collectMenuItems(element.props.children),
- };
- }
-
- return null;
-}
-
-function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] {
- const items: NativeStackHeaderItemMenu["menu"]["items"] = [];
- Children.forEach(children, (child) => {
- if (!isValidElement(child)) {
- return;
- }
- const item = convertMenuAction(child);
- if (item) {
- items.push(item);
- return;
- }
- items.push(...collectMenuItems(child.props.children));
- });
- return items;
-}
-
-function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
- if (!isValidElement(child)) {
- return null;
- }
-
- const typeName = elementTypeName(child);
- if (typeName === "NativeHeaderToolbarButton") {
- return {
- type: "button",
- label: typeof child.props.label === "string" ? child.props.label : "",
- accessibilityLabel:
- typeof child.props.accessibilityLabel === "string"
- ? child.props.accessibilityLabel
- : undefined,
- disabled: Boolean(child.props.disabled),
- icon: iconFromProp(child.props.icon),
- onPress:
- typeof child.props.onPress === "function"
- ? (child.props.onPress as () => void)
- : () => undefined,
- sharesBackground: !child.props.separateBackground,
- tintColor: child.props.tintColor as ColorValue | undefined,
- variant: "plain",
- };
- }
-
- if (typeName === "NativeHeaderToolbarMenu") {
- return {
- type: "menu",
- label: typeof child.props.title === "string" ? child.props.title : "",
- accessibilityLabel:
- typeof child.props.accessibilityLabel === "string"
- ? child.props.accessibilityLabel
- : undefined,
- disabled: Boolean(child.props.disabled),
- icon: iconFromProp(child.props.icon),
- menu: {
- title: typeof child.props.title === "string" ? child.props.title : undefined,
- items: collectMenuItems(child.props.children),
- },
- sharesBackground: !child.props.separateBackground,
- tintColor: child.props.tintColor as ColorValue | undefined,
- variant: "plain",
- };
- }
-
- if (typeName === "NativeHeaderToolbarSpacer") {
- return {
- type: "spacing",
- spacing: typeof child.props.width === "number" ? child.props.width : 8,
- flexible: Boolean(child.props.flexible),
- } as NativeStackHeaderItem;
- }
-
- return null;
-}
-
-function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] {
- const items: NativeStackHeaderItem[] = [];
- Children.forEach(children, (child) => {
- const item = convertToolbarChild(child);
- if (item) {
- if (item.type === "spacing") {
- // Native inserts spacing items at `index`, treating a missing index
- // as 0 — which would move the spacer in front of earlier siblings.
- (item as { index?: number }).index = items.length;
- }
- items.push(item);
- }
- });
- return items;
-}
-
-function NativeHeaderToolbarRoot(props: {
- readonly placement?: "left" | "right" | "bottom";
- readonly children?: ReactNode;
-}) {
- const navigation = useNativeStackNavigation();
- const items = useMemo(() => collectToolbarItems(props.children), [props.children]);
-
- // Swap toolbar owners before paint so split and compact headers cannot clear each other.
- useLayoutEffect(() => {
- if (!navigation) {
- return;
- }
- if (props.placement === "bottom") {
- navigation.setOptions({
- unstable_headerToolbarItems: () => items,
- } as NativeStackOptionsWithToolbar);
- return () => {
- navigation.setOptions({
- unstable_headerToolbarItems: () => [],
- } as NativeStackOptionsWithToolbar);
- };
- }
- if (props.placement === "left") {
- navigation.setOptions({ unstable_headerLeftItems: () => items });
- return () => {
- navigation.setOptions({ unstable_headerLeftItems: () => [] });
- };
- }
- navigation.setOptions({ unstable_headerRightItems: () => items });
- return () => {
- navigation.setOptions({ unstable_headerRightItems: () => [] });
- };
- }, [items, navigation, props.placement]);
-
- return null;
-}
-
-function NativeHeaderToolbarButton(_props: {
- readonly accessibilityLabel?: string;
- readonly disabled?: boolean;
- readonly icon?: string;
- readonly label?: string;
- readonly onPress?: () => void;
- readonly separateBackground?: boolean;
- readonly tintColor?: ColorValue;
-}) {
- return null;
-}
-NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton";
-
-function NativeHeaderToolbarMenu(_props: {
- readonly accessibilityLabel?: string;
- readonly children?: ReactNode;
- readonly disabled?: boolean;
- readonly icon?: string;
- readonly inline?: boolean;
- readonly separateBackground?: boolean;
- readonly tintColor?: ColorValue;
- readonly title?: string;
-}) {
- return null;
-}
-NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu";
-
-function NativeHeaderToolbarMenuAction(_props: {
- readonly children?: ReactNode;
- readonly destructive?: boolean;
- readonly disabled?: boolean;
- readonly discoverabilityLabel?: string;
- readonly icon?: string;
- readonly isOn?: boolean;
- readonly onPress?: () => void;
- readonly subtitle?: string;
-}) {
- return null;
-}
-NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
-
-function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
- return null;
-}
-NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel";
-
-function NativeHeaderToolbarSpacer(_props: {
- readonly flexible?: boolean;
- readonly sharesBackground?: boolean;
- readonly width?: number;
-}) {
- return null;
-}
-NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer";
-
-function NativeHeaderToolbarSearchBarSlot() {
- return null;
-}
-NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot";
-
-export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
- Button: NativeHeaderToolbarButton,
- Label: NativeHeaderToolbarLabel,
- Menu: Object.assign(NativeHeaderToolbarMenu, {
- Action: NativeHeaderToolbarMenuAction,
- }),
- MenuAction: NativeHeaderToolbarMenuAction,
- SearchBarSlot: NativeHeaderToolbarSearchBarSlot,
- Spacer: NativeHeaderToolbarSpacer,
-});
From 989bde889dd119f5919ce27c9d56db77efedec4c Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:15 -0700
Subject: [PATCH 024/111] refactor(mobile): configure thread headers through
the shared core (#12389)
---
.../features/threads/ThreadRouteScreen.tsx | 327 +++++++-----------
.../threads/useThreadHeaderOptions.android.ts | 11 +
.../threads/useThreadHeaderOptions.tsx | 119 +++++++
3 files changed, 250 insertions(+), 207 deletions(-)
create mode 100644 apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts
create mode 100644 apps/mobile/src/features/threads/useThreadHeaderOptions.tsx
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 9d0410c7e9c2..e5bfc1590cf0 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -6,7 +6,10 @@ import {
} from "../../state/use-composer-drafts";
import { useWorktreeSetup } from "./use-worktree-setup";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
-import { NativeStackScreenOptions } from "../../native/StackHeader";
+import { ScreenHeader } from "../../components/ScreenHeader";
+import { ScreenHeaderButton } from "../../components/ScreenHeaderButton";
+import type { ScreenHeaderAction } from "../../components/ScreenHeader.types";
+import { useThreadHeaderOptions } from "./useThreadHeaderOptions";
import {
StackActions,
useFocusEffect,
@@ -43,12 +46,6 @@ import { useEnvironmentQuery } from "../../state/query";
import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
import { vcsEnvironment } from "../../state/vcs";
import { EmptyState } from "../../components/EmptyState";
-import {
- AndroidHeaderIconButton,
- AndroidScreenHeader,
- type AndroidHeaderAction,
-} from "../../components/AndroidScreenHeader";
-import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
import { LoadingScreen } from "../../components/LoadingScreen";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
@@ -73,11 +70,6 @@ import {
} from "../terminal/terminalLaunchContext";
import { terminalDebugLog } from "../terminal/terminalDebugLog";
import { ThreadDetailScreen, type ThreadDetailScreenProps } from "./ThreadDetailScreen";
-import {
- ThreadGitControls,
- useThreadGitCenterHeaderItems,
- useThreadGitRightHeaderItems,
-} from "./ThreadGitControls";
import { GitOverviewSheet } from "./git/GitOverviewSheet";
import { useAtomCommand } from "../../state/use-atom-command";
import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
@@ -93,7 +85,6 @@ import {
useAdaptiveWorkspacePaneRole,
useRegisterWorkspaceInspector,
} from "../layout/AdaptiveWorkspaceLayout";
-import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import { ThreadFileNavigatorPane } from "../files/thread-file-navigator-pane";
import {
ThreadInspectorContentStack,
@@ -101,13 +92,109 @@ import {
} from "./thread-inspector-content-stack";
import { threadRouteIsHydrating } from "./thread-route-hydration";
+function ThreadHeader(
+ props: Parameters[0] & {
+ readonly hasThreadCwd: boolean;
+ readonly hasWorkspaceRoot: boolean;
+ readonly fileInspectorSupported: boolean;
+ readonly inspectorMode: ThreadInspectorMode | null;
+ readonly onToggleInspector: () => void;
+ readonly onOpenGitInspector: () => void;
+ readonly onOpenFilesInspector: () => void;
+ },
+) {
+ const navigation = useNavigation();
+ const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
+ const { onOpenTerminal } = props.gitControls;
+ const native = useThreadHeaderOptions(props);
+ const androidHeaderActions = useMemo>(() => {
+ const actions: ScreenHeaderAction[] = [];
+ if (props.onReturnToThread) {
+ actions.push({
+ accessibilityLabel: "Return to chat",
+ icon: "chevron.left",
+ onPress: props.onReturnToThread,
+ });
+ }
+ if (props.hasThreadCwd) {
+ const filesVisible = props.inspectorMode === "files" && panes.auxiliaryPaneVisible;
+ actions.push({
+ accessibilityLabel: filesVisible ? "Close files" : "Open files",
+ selected: filesVisible,
+ icon: "folder",
+ onPress: filesVisible ? toggleAuxiliaryPane : props.onOpenFilesInspector,
+ });
+ }
+ if (props.hasWorkspaceRoot) {
+ actions.push({
+ accessibilityLabel: "Open terminal",
+ icon: "terminal",
+ onPress: () => onOpenTerminal(null),
+ });
+ }
+ actions.push({
+ accessibilityLabel: "Open git controls",
+ icon: "point.topleft.down.curvedto.point.bottomright.up",
+ onPress: props.onOpenGitInspector,
+ });
+ return actions;
+ }, [
+ props.inspectorMode,
+ panes.auxiliaryPaneVisible,
+ props.onOpenFilesInspector,
+ onOpenTerminal,
+ props.onOpenGitInspector,
+ toggleAuxiliaryPane,
+ props.onReturnToThread,
+ props.hasThreadCwd,
+ props.hasWorkspaceRoot,
+ ]);
+
+ return (
+ <>
+
+ ) : null
+ }
+ onBack={
+ layout.usesSplitView
+ ? undefined
+ : () => {
+ // A deep link or cold start has no previous route; Home is the way out.
+ // Read the history at press time: it changes without re-rendering this screen.
+ if (navigation.canGoBack()) navigation.goBack();
+ else navigation.dispatch(StackActions.replace("Home"));
+ }
+ }
+ actions={androidHeaderActions}
+ hideBottomBorder
+ />
+ {native.fallback}
+ >
+ );
+}
+
interface ThreadInspectorSelection {
readonly routeThreadIdentity: string | null;
readonly mode: ThreadInspectorMode;
}
-type NativeHeaderItems = ReadonlyArray>;
-
function InspectorPaneRoleActivation() {
useAdaptiveWorkspacePaneRole("inspector");
return null;
@@ -237,14 +324,8 @@ function ThreadRouteContent(
) {
const { themeVariables } = useAppearancePreferences();
const headerColor = themeVariables["--color-header"];
- const {
- fileInspector,
- layout,
- panes,
- showAuxiliaryPane,
- toggleAuxiliaryPane,
- togglePrimarySidebar,
- } = useAdaptiveWorkspaceLayout();
+ const { fileInspector, layout, panes, showAuxiliaryPane, toggleAuxiliaryPane } =
+ useAdaptiveWorkspaceLayout();
const { connectionState } = useRemoteConnectionStatus();
const { onReconnectEnvironment } = useRemoteConnections();
const {
@@ -701,94 +782,6 @@ function ThreadRouteContent(
onPull: gitActions.onPullSelectedThreadBranch,
onRunAction: gitActions.onRunSelectedThreadGitAction,
};
- const threadCenterHeaderItems = useThreadGitCenterHeaderItems(threadGitControlProps);
- const compactRightHeaderItems = useThreadGitRightHeaderItems(threadGitControlProps);
- const splitLeftHeaderItems = useMemo(
- () => [
- {
- // Match Mail's split-view detail toolbar: the first detail action sits
- // inside the content pane, not flush against the sidebar divider.
- spacing: 18,
- type: "spacing" as const,
- },
- ...(props.onReturnToThread
- ? [
- withNativeGlassHeaderItem({
- accessibilityLabel: "Return to chat",
- icon: { name: "chevron.left", type: "sfSymbol" as const },
- identifier: "thread-left-return",
- onPress: props.onReturnToThread,
- type: "button" as const,
- }),
- ]
- : []),
- withNativeGlassHeaderItem({
- accessibilityLabel: panes.primarySidebarVisible
- ? "Maximize content"
- : "Show thread sidebar",
- icon: {
- name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
- type: "sfSymbol" as const,
- },
- identifier: "thread-left-sidebar",
- onPress: togglePrimarySidebar,
- type: "button" as const,
- }),
- withNativeGlassHeaderItem({
- accessibilityLabel: "New task",
- icon: { name: "square.and.pencil", type: "sfSymbol" as const },
- identifier: "thread-left-new-task",
- onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
- type: "button" as const,
- }),
- ],
- [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
- );
- const androidHeaderActions = useMemo>(() => {
- if (Platform.OS !== "android") return [];
-
- const actions: AndroidHeaderAction[] = [];
- if (props.onReturnToThread) {
- actions.push({
- accessibilityLabel: "Return to chat",
- icon: "chevron.left",
- onPress: props.onReturnToThread,
- });
- }
- if (selectedThreadCwd !== null) {
- const filesVisible = inspectorMode === "files" && panes.auxiliaryPaneVisible;
- actions.push({
- accessibilityLabel: filesVisible ? "Close files" : "Open files",
- selected: filesVisible,
- icon: "folder",
- onPress: filesVisible ? toggleAuxiliaryPane : handleOpenFilesInspector,
- });
- }
- if (selectedThreadProject?.workspaceRoot) {
- actions.push({
- accessibilityLabel: "Open terminal",
- icon: "terminal",
- onPress: () => handleOpenTerminal(null),
- });
- }
- actions.push({
- accessibilityLabel: "Open git controls",
- icon: "point.topleft.down.curvedto.point.bottomright.up",
- onPress: handleOpenGitInspector,
- });
- return actions;
- }, [
- inspectorMode,
- panes.auxiliaryPaneVisible,
- handleOpenFilesInspector,
- handleOpenTerminal,
- handleOpenGitInspector,
- toggleAuxiliaryPane,
- props.onReturnToThread,
- selectedThreadCwd,
- selectedThreadProject?.workspaceRoot,
- ]);
-
const handleEditFailedCreation = useCallback(async () => {
const creation = selectedThreadCreation?.message;
if (!creation?.creation || routeThreadIdentity === null) {
@@ -929,23 +922,6 @@ function ThreadRouteContent(
preparingWorktree: selectedThreadCreation.message.creation?.workspaceMode === "worktree",
};
})();
- // Deep links / cold starts land with Thread as the ONLY route, where the
- // native back button does not render. Provide an explicit Home escape for
- // that case; when history exists the native back button is used instead.
- const canGoBack = navigation.canGoBack();
- const compactHomeHeaderItems = useMemo(
- () => [
- withNativeGlassHeaderItem({
- accessibilityLabel: "Go to threads list",
- icon: { name: "list.bullet", type: "sfSymbol" as const },
- identifier: "thread-left-home",
- onPress: () => navigation.dispatch(StackActions.replace("Home")),
- type: "button" as const,
- }),
- ],
- [navigation],
- );
-
if (!environmentId || !threadId) {
return ;
}
@@ -967,10 +943,8 @@ function ThreadRouteContent(
connectionState: routeConnectionState,
});
const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null;
- const renderThreadRouteBody = (showActionControls: boolean) => (
+ const renderThreadRouteBody = () => (
<>
-
-
{activeInspectorRenderer ? : null}
- splitLeftHeaderItems
- : canGoBack
- ? undefined
- : () => compactHomeHeaderItems
- : undefined,
- // Search lives in the persistent sidebar, so the split header keeps
- // the git controls on the RIGHT (no center items — center space is
- // reserved for future breadcrumbs/status).
- unstable_headerRightItems:
- Platform.OS === "ios"
- ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems)
- : undefined,
- unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined,
- contentStyle:
- Platform.OS === "android" && true ? { backgroundColor: headerColor } : undefined,
- }}
+
- {Platform.OS === "android" ? (
- }
- trailing={
- fileInspector.supported && selectedThreadCwd !== null ? (
-
- ) : null
- }
- onBack={
- layout.usesSplitView
- ? undefined
- : () => {
- // A deep link or cold start has no previous route; Home is the way out.
- // Read the history at press time: it changes without re-rendering this screen.
- if (navigation.canGoBack()) navigation.goBack();
- else navigation.dispatch(StackActions.replace("Home"));
- }
- }
- actions={androidHeaderActions}
- hideBottomBorder
- />
- ) : null}
-
- {/* Android surfaces the git/files/inspector actions in its in-flow
- header above, so the fallback action toolbar stays iOS-only. */}
- {renderThreadRouteBody(
- Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass,
- )}
+ {renderThreadRouteBody()}
>
);
}
diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts
new file mode 100644
index 000000000000..7ddf716fce0f
--- /dev/null
+++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.android.ts
@@ -0,0 +1,11 @@
+import type { useThreadHeaderOptions as useIosThreadHeaderOptions } from "./useThreadHeaderOptions";
+
+export function useThreadHeaderOptions(
+ props: Parameters[0],
+): ReturnType {
+ return {
+ options: { contentStyle: { backgroundColor: props.headerColor } },
+ sidebar: true,
+ fallback: null,
+ };
+}
diff --git a/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx
new file mode 100644
index 000000000000..b8dd5112a36e
--- /dev/null
+++ b/apps/mobile/src/features/threads/useThreadHeaderOptions.tsx
@@ -0,0 +1,119 @@
+import { StackActions, useNavigation } from "@react-navigation/native";
+import { useMemo } from "react";
+import type { AppNativeStackNavigationOptions } from "../../native/StackHeader";
+import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
+import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
+import {
+ ThreadGitControls,
+ useThreadGitCenterHeaderItems,
+ useThreadGitRightHeaderItems,
+} from "./ThreadGitControls";
+
+type NativeHeaderItems = ReadonlyArray>;
+
+export function useThreadHeaderOptions(props: {
+ readonly title: string;
+ readonly subtitle: string;
+ readonly headerColor: string;
+ readonly usesNativeHeaderGlass: boolean;
+ readonly gitControls: Parameters[0];
+ readonly onReturnToThread?: () => void;
+}) {
+ const navigation = useNavigation();
+ const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
+ const threadCenterHeaderItems = useThreadGitCenterHeaderItems(props.gitControls);
+ const compactRightHeaderItems = useThreadGitRightHeaderItems(props.gitControls);
+ const splitLeftHeaderItems = useMemo(
+ () => [
+ {
+ // Match Mail's split-view detail toolbar: the first detail action sits
+ // inside the content pane, not flush against the sidebar divider.
+ spacing: 18,
+ type: "spacing" as const,
+ },
+ ...(props.onReturnToThread
+ ? [
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Return to chat",
+ icon: { name: "chevron.left", type: "sfSymbol" as const },
+ identifier: "thread-left-return",
+ onPress: props.onReturnToThread,
+ type: "button" as const,
+ }),
+ ]
+ : []),
+ withNativeGlassHeaderItem({
+ accessibilityLabel: panes.primarySidebarVisible
+ ? "Maximize content"
+ : "Show thread sidebar",
+ icon: {
+ name: panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left",
+ type: "sfSymbol" as const,
+ },
+ identifier: "thread-left-sidebar",
+ onPress: togglePrimarySidebar,
+ type: "button" as const,
+ }),
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "New task",
+ icon: { name: "square.and.pencil", type: "sfSymbol" as const },
+ identifier: "thread-left-new-task",
+ onPress: () => navigation.navigate("NewTaskSheet", { screen: "NewTask" }),
+ type: "button" as const,
+ }),
+ ],
+ [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
+ );
+ // Deep links / cold starts land with Thread as the ONLY route, where the
+ // native back button does not render. Provide an explicit Home escape for
+ // that case; when history exists the native back button is used instead.
+ const canGoBack = navigation.canGoBack();
+ const compactHomeHeaderItems = useMemo(
+ () => [
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Go to threads list",
+ icon: { name: "list.bullet", type: "sfSymbol" as const },
+ identifier: "thread-left-home",
+ onPress: () => navigation.dispatch(StackActions.replace("Home")),
+ type: "button" as const,
+ }),
+ ],
+ [navigation],
+ );
+
+ const options: AppNativeStackNavigationOptions = {
+ headerShown: true,
+ headerTitle: props.title,
+ headerTitleStyle: props.usesNativeHeaderGlass
+ ? {
+ fontSize: 17,
+ fontWeight: "800",
+ }
+ : undefined,
+ title: props.title,
+ headerBackVisible: !layout.usesSplitView,
+ // Compact uses the NATIVE back button when a previous route exists;
+ // deep links / cold starts get an explicit Home button instead.
+ // Split view always uses its custom left items.
+ unstable_headerLeftItems: layout.usesSplitView
+ ? () => splitLeftHeaderItems
+ : canGoBack
+ ? undefined
+ : () => compactHomeHeaderItems,
+ // Search lives in the persistent sidebar, so the split header keeps
+ // the git controls on the RIGHT (no center items — center space is
+ // reserved for future breadcrumbs/status).
+ unstable_headerRightItems: () =>
+ layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems,
+ unstable_headerSubtitle: props.usesNativeHeaderGlass ? props.subtitle : undefined,
+ contentStyle: undefined,
+ };
+ return {
+ options,
+ sidebar: false,
+ fallback:
+ !layout.usesSplitView && !props.usesNativeHeaderGlass ? (
+
+ ) : null,
+ };
+}
From 54bbed660fc5dc99c6bfe335981e6d431275c91e Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:15 -0700
Subject: [PATCH 025/111] refactor(mobile): share file header actions and
search configuration (#12390)
---
.../features/files/ThreadFilesRouteScreen.tsx | 327 +++++++-----------
1 file changed, 121 insertions(+), 206 deletions(-)
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index 2cfe50de830a..9e89ea1b24e4 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -1,6 +1,5 @@
-import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
+import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
-import type { MenuAction } from "@react-native-menu/menu";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -14,10 +13,8 @@ import {
} from "@t3tools/shared/filePreview";
import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
-import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { AudioFilePreview } from "../../components/AudioFilePreview";
-import { ControlPillMenu } from "../../components/ControlPill";
import { EmptyState } from "../../components/EmptyState";
import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
import { LoadingScreen } from "../../components/LoadingScreen";
@@ -38,14 +35,6 @@ import {
useAdaptiveWorkspacePaneRole,
useRegisterWorkspaceInspector,
} from "../layout/AdaptiveWorkspaceLayout";
-import {
- createNativeMailSearchToolbarItem,
- NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
-} from "../layout/native-mail-search-toolbar";
-import {
- AndroidWorkspaceSidebarButton,
- WorkspaceSidebarToolbar,
-} from "../layout/workspace-sidebar-toolbar";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { ThreadRouteScreen } from "../threads/ThreadRouteScreen";
import { FilePreviewLoading, FilePreviewNotice } from "./FilePreviewFeedback";
@@ -55,7 +44,7 @@ import { useFileTreeEntries } from "./useFileTreeEntries";
import { preloadWorkspaceFileContents } from "./preload-workspace-file";
import { SourceFileSurface } from "./SourceFileSurface";
import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane";
-import { MaterialFilesHeader } from "./MaterialFilesHeader";
+import { ScreenHeader } from "../../components/ScreenHeader";
import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview";
import { WorkspaceFilePreviewError } from "./WorkspaceFilePreviewError";
import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview";
@@ -70,6 +59,107 @@ import {
} from "./filePath";
import { useWorkspaceFileAssetUrlState } from "./workspaceFileAssetUrl";
+function FilesBrowserHeader(props: {
+ readonly projectName: string;
+ readonly searchQuery: string;
+ readonly onSearchQueryChange: (query: string) => void;
+ readonly onRefresh: () => void;
+ readonly onBack: () => void;
+}) {
+ return (
+
+ );
+}
+
+function FileHeader(props: {
+ readonly title: string;
+ readonly subtitle: string;
+ readonly iconColor: string;
+ readonly activeMode: string;
+ readonly fileInspectorSupported: boolean;
+ readonly onBack: () => void;
+ readonly onReturnToThread: () => void;
+ readonly actions: ReadonlyArray<{
+ readonly id: string;
+ readonly title: string;
+ readonly icon: string;
+ readonly inline: boolean;
+ readonly onPress: () => unknown;
+ }>;
+}) {
+ const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
+ const modes = props.actions.filter(({ inline }) => inline);
+ return (
+ 0
+ ? [
+ {
+ id: "modes",
+ inline: true,
+ items: modes.map((action) => ({
+ ...action,
+ selected: action.id === props.activeMode,
+ })),
+ },
+ ]
+ : []),
+ ...props.actions.filter(({ inline }) => !inline),
+ ],
+ },
+ ]}
+ />
+ );
+}
+
type FileViewMode = "preview" | "source";
function firstRouteParam(value: string | string[] | undefined): string | null {
@@ -315,14 +405,10 @@ function FilesToolbarBottomFade() {
export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
useAdaptiveWorkspacePaneRole("inspector");
const navigation = useNavigation();
- const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
- useAdaptiveWorkspaceLayout();
+ const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
const [searchQuery, setSearchQuery] = useState("");
- const isAndroid = Platform.OS === "android";
const { themeAppearance: highlightTheme } = useAppearancePreferences();
- const theme = useUniwindTheme();
- const headerColor = theme["--color-header"];
- const sheetSurfaceColor = theme["--color-sheet-solid"];
+ const headerColor = useUniwindTheme()["--color-header"];
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
props.route.params,
);
@@ -430,84 +516,15 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
);
}
- const usesCompactMailToolbar =
- Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
-
const content = (
<>
- {/* Static header config (glass preset and title) lives in Stack.tsx. The
- live sheet color stays dynamic here so the FlatList can remain the
- direct scene child for native scroll-edge sampling. */}
- 0 ? projectName : undefined,
- // No refresh button: the list already supports pull-to-refresh.
- unstable_headerToolbarItems: usesCompactMailToolbar
- ? () => [
- createNativeMailSearchToolbarItem({
- onSearchTextChange: setSearchQuery,
- placeholder: "Search files",
- searchTextChangeId: "files-search-text",
- }),
- ]
- : undefined,
- headerSearchBarOptions: usesCompactMailToolbar
- ? undefined
- : {
- allowToolbarIntegration: true,
- autoCapitalize: "none",
- hideNavigationBar: false,
- placeholder: "Search files",
- onChangeText: (event) => {
- setSearchQuery(event.nativeEvent.text);
- },
- onCancelButtonPress: () => {
- setSearchQuery("");
- },
- },
- }}
+
- {isAndroid ? (
- <>
- {
- }
- searchQuery={searchQuery}
- onSearchQueryChange={setSearchQuery}
- onRefresh={entriesQuery.refresh}
- onBack={handleReturnToThread}
- />
- }
- >
- ) : (
- <>
- {layout.usesSplitView ? (
-
-
-
- ) : null}
- {usesCompactMailToolbar ? null : (
-
-
-
- )}
- >
- )}
(
- () =>
- fileMenuActions.map((action) => ({
- id: action.id,
- title: action.title,
- image: action.icon,
- state: action.id === resolvedActiveMode ? "on" : undefined,
- })),
- [fileMenuActions, resolvedActiveMode],
- );
- const handleAndroidFileMenuAction = useCallback(
- (event: { nativeEvent: { event: string } }) => {
- const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event);
- void action?.onPress();
- },
- [fileMenuActions],
- );
const handleReturnToThread = useCallback(() => {
if (environmentId !== null && threadId !== null) {
navigation.dispatch(
@@ -878,100 +877,16 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
return (
- 0 ? headerSubtitle : undefined,
- }}
+
- {isAndroid ? (
- }
- hideBottomBorder
- onBack={handleBack}
- trailing={
- <>
- {fileInspector.supported ? (
-
- ) : null}
-
-
-
- >
- }
- />
- ) : null}
-
- {fileInspector.supported ? (
-
- ) : null}
-
-
- {fileInspector.supported ? (
-
- ) : null}
-
- {fileMenuActions.some(({ inline }) => inline) ? (
-
- {fileMenuActions
- .filter(({ inline }) => inline)
- .map((action) => (
-
- {action.title}
-
- ))}
-
- ) : null}
- {fileMenuActions
- .filter(({ inline }) => !inline)
- .map((action) => (
-
- {action.title}
-
- ))}
-
-
Date: Fri, 18 Sep 2026 12:55:15 -0700
Subject: [PATCH 026/111] refactor(mobile): share terminal header and menu
configuration (#12391)
---
.../terminal/ThreadTerminalRouteScreen.tsx | 277 +++++++-----------
1 file changed, 100 insertions(+), 177 deletions(-)
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
index e490200acf8d..794cb9dce476 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
@@ -1,8 +1,7 @@
import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts";
import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
-import type { MenuAction } from "@react-native-menu/menu";
import { SymbolView } from "../../components/AppSymbol";
-import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
+import { ScreenHeader } from "../../components/ScreenHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, Pressable, View } from "react-native";
@@ -19,13 +18,11 @@ import {
useKeyboardState,
} from "react-native-keyboard-controller";
-import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import {
ComposerToolbarButton,
ComposerToolbarRow,
ComposerToolbarScroller,
} from "../../components/ComposerToolbar";
-import { ControlPillMenu } from "../../components/ControlPill";
import { EmptyState } from "../../components/EmptyState";
import { GlassSurface } from "../../components/GlassSurface";
import { LoadingScreen } from "../../components/LoadingScreen";
@@ -52,8 +49,6 @@ import {
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadDetail } from "../../state/use-thread-detail";
import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
-import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
-import { AndroidWorkspaceSidebarButton } from "../layout/workspace-sidebar-toolbar";
import { TerminalSurface } from "./NativeTerminalSurface";
import { getMobileTerminalTheme } from "./terminalTheme";
import { terminalDebugLog } from "./terminalDebugLog";
@@ -85,6 +80,90 @@ import {
import { createTerminalPasteSession } from "./terminalPaste";
import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
+function TerminalHeader(props: {
+ readonly subtitle: string;
+ readonly isEnvironmentReady: boolean;
+ readonly fontSize: number;
+ readonly terminalId: string;
+ readonly sessions: ReadonlyArray;
+ readonly status: Parameters[0];
+ readonly workspaceRoot: string;
+ readonly onCloseTerminal: () => void;
+ readonly onDecreaseFontSize: () => void;
+ readonly onIncreaseFontSize: () => void;
+ readonly onOpenNewTerminal: () => void;
+ readonly onSelectTerminal: (terminalId: string) => void;
+}) {
+ return (
+ = MAX_TERMINAL_FONT_SIZE,
+ onPress: props.onIncreaseFontSize,
+ },
+ ],
+ },
+ ...props.sessions.map((session) => ({
+ id: `terminal-session:${session.terminalId}`,
+ title: session.displayLabel,
+ icon: "terminal",
+ subtitle: [
+ getTerminalStatusLabel({
+ status: session.status,
+ hasRunningSubprocess: session.hasRunningSubprocess,
+ }),
+ basename(session.cwd),
+ ]
+ .filter(Boolean)
+ .join(" · "),
+ selected: session.terminalId === props.terminalId,
+ onPress: () => props.onSelectTerminal(session.terminalId),
+ })),
+ {
+ id: "terminal-new",
+ title: "Open new terminal",
+ icon: "plus",
+ subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`,
+ onPress: props.onOpenNewTerminal,
+ },
+ ],
+ },
+ ]
+ : undefined
+ }
+ />
+ );
+}
+
const DEFAULT_TERMINAL_COLS = 80;
const DEFAULT_TERMINAL_ROWS = 24;
const TERMINAL_ACCESSORY_HEIGHT = 52;
@@ -171,7 +250,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
const { state: workspaceState } = useWorkspaceState();
- const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
const params = props.route.params;
const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } =
useThreadSelection();
@@ -483,7 +561,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
);
const terminalTheme = getMobileTerminalTheme(themeId, appearanceScheme);
- const usesNativeHeaderGlass = Platform.OS === "ios";
const pendingModifier =
pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
const headerSubtitle = selectedThreadProject?.title ?? "";
@@ -1003,67 +1080,6 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
setTerminalFontSize(stepTerminalFontSize(fontSize, 1));
}, [fontSize, setTerminalFontSize]);
- // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text
- // size, session switching, and "Open new terminal", rendered through the
- // token-styled anchored menu (the native header items are iOS-only).
- const androidTerminalMenuActions = useMemo(
- () => [
- {
- id: "text-size",
- title: "Text size",
- subactions: [
- {
- id: "font-decrease",
- title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`,
- attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined,
- },
- {
- id: "font-increase",
- title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`,
- attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined,
- },
- ],
- },
- ...terminalMenuSessions.map((session): MenuAction => ({
- id: `terminal-session:${session.terminalId}`,
- title: session.displayLabel,
- subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)]
- .filter(Boolean)
- .join(" · "),
- state: session.terminalId === terminalId ? ("on" as const) : undefined,
- })),
- {
- id: "terminal-new",
- title: "Open new terminal",
- image: "plus",
- subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`,
- },
- ],
- [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions],
- );
-
- const handleAndroidTerminalMenuAction = useCallback(
- (event: { nativeEvent: { event: string } }) => {
- const id = event.nativeEvent.event;
- if (id === "font-decrease") {
- handleDecreaseFontSize();
- return;
- }
- if (id === "font-increase") {
- handleIncreaseFontSize();
- return;
- }
- if (id === "terminal-new") {
- handleOpenNewTerminal();
- return;
- }
- if (id.startsWith("terminal-session:")) {
- handleSelectTerminal(id.slice("terminal-session:".length));
- }
- },
- [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal],
- );
-
const handleClearTerminal = useCallback(() => {
if (!selectedThread) {
return;
@@ -1169,117 +1185,24 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
}}
/>
) : null}
- 0 ? headerSubtitle : undefined,
+
- {Platform.OS === "android" ? (
- }
- onBack={handleCloseTerminal}
- trailing={
- <>
- {isEnvironmentReady ? (
-
-
-
- ) : null}
- >
- }
- />
- ) : null}
-
- {layout.usesSplitView ? (
-
-
-
-
- ) : null}
-
- {isEnvironmentReady ? (
-
-
-
- {getTerminalStatusLabel({
- status: terminal.status,
- hasRunningSubprocess: terminal.hasRunningSubprocess,
- })}
-
-
- Text size
-
- {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}
-
- = MAX_TERMINAL_FONT_SIZE}
- discoverabilityLabel="Increase terminal text size"
- onPress={handleIncreaseFontSize}
- >
- {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`}
-
-
- {terminalMenuSessions.map((session) => (
- handleSelectTerminal(session.terminalId)}
- subtitle={[
- getTerminalStatusLabel({ status: session.status }),
- basename(session.cwd),
- ]
- .filter(Boolean)
- .join(" · ")}
- >
- {session.displayLabel}
-
- ))}
-
- Open new terminal
-
-
-
- ) : null}
-
Date: Fri, 18 Sep 2026 12:55:16 -0700
Subject: [PATCH 027/111] refactor(mobile): share archived thread header
configuration (#12399)
---
.../archive/ArchivedThreadsScreen.tsx | 393 ++++--------------
1 file changed, 80 insertions(+), 313 deletions(-)
diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
index 6161c203f11d..a191a9c7175b 100644
--- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
+++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
@@ -8,15 +8,12 @@ import {
type EnvironmentMachineKind,
resolveEnvironmentMachineKind,
} from "@t3tools/contracts";
-import type { MenuAction } from "@react-native-menu/menu";
-import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
-import { SymbolView } from "../../components/AppSymbol";
import { useNavigation } from "@react-navigation/native";
+import { ScreenHeader } from "../../components/ScreenHeader";
+import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useMemo, useRef, type ComponentProps } from "react";
import {
- TextInput,
ActivityIndicator,
- Platform,
Pressable,
RefreshControl,
useWindowDimensions,
@@ -26,19 +23,13 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { AppText as Text } from "../../components/AppText";
-import { ControlPillMenu } from "../../components/ControlPill";
import { EmptyState } from "../../components/EmptyState";
import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
import { ProjectFavicon } from "../../components/ProjectFavicon";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { relativeTime } from "../../lib/time";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useServerConfigs } from "../../state/entities";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
-import {
- createNativeMailSearchToolbarItem,
- NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
-} from "../layout/native-mail-search-toolbar";
import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList";
import { SettingsScreenContent } from "../settings/components/SettingsScreen";
@@ -47,23 +38,6 @@ export interface ArchivedThreadsHeaderEnvironment {
readonly label: string;
}
-type ArchivedThreadListItem =
- | {
- readonly kind: "project";
- readonly key: string;
- readonly environmentLabel: string | null;
- readonly environmentMachine: EnvironmentMachineKind;
- readonly project: EnvironmentProject;
- }
- | {
- readonly kind: "thread";
- readonly key: string;
- readonly environmentLabel: string | null;
- readonly isFirst: boolean;
- readonly isLast: boolean;
- readonly thread: EnvironmentThreadShell;
- };
-
function ArchivedThreadsHeader(props: {
readonly environments: ReadonlyArray;
readonly searchQuery: string;
@@ -74,299 +48,92 @@ function ArchivedThreadsHeader(props: {
readonly onSearchQueryChange: (query: string) => void;
readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void;
}) {
- const { width } = useWindowDimensions();
const navigation = useNavigation();
- const insets = useSafeAreaInsets();
+ const { width } = useWindowDimensions();
const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
- const usesNativeChrome = Platform.OS === "ios";
- const usesCompactMailToolbar =
- Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
- const androidFilterActions = useMemo(
- () => [
- {
- id: "environment",
- title: "Environment",
- subactions: [
- {
- id: "environment:all",
- title: "All environments",
- state: props.selectedEnvironmentId === null ? ("on" as const) : undefined,
- },
- ...props.environments.map((environment) => ({
- id: `environment:${environment.environmentId}`,
- title: environment.label,
- state:
- props.selectedEnvironmentId === environment.environmentId
- ? ("on" as const)
- : undefined,
- })),
- ],
- },
- {
- id: "sort",
- title: "Sort by archived date",
- subactions: [
- {
- id: "sort:newest",
- title: "Newest first",
- state: props.sortOrder === "newest" ? ("on" as const) : undefined,
- },
- {
- id: "sort:oldest",
- title: "Oldest first",
- state: props.sortOrder === "oldest" ? ("on" as const) : undefined,
- },
- ],
- },
- ],
- [props.environments, props.selectedEnvironmentId, props.sortOrder],
- );
- const handleAndroidFilterAction = useCallback(
- (event: { nativeEvent: { event: string } }) => {
- const action = event.nativeEvent.event;
- if (action === "environment:all") {
- props.onEnvironmentChange(null);
- } else if (action.startsWith("environment:")) {
- props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId);
- } else if (action === "sort:newest") {
- props.onSortOrderChange("newest");
- } else if (action === "sort:oldest") {
- props.onSortOrderChange("oldest");
- }
- },
- [props.onEnvironmentChange, props.onSortOrderChange],
- );
-
- if (Platform.OS === "android") {
- // Single header row matching the app's Android chrome (AndroidScreenHeader
- // palette): back chevron, inline search, filter menu.
- return (
- <>
-
-
-
- navigation.goBack()}
- className="size-11 items-center justify-center"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
- }
- const archiveFilterMenu = {
- title: "Archived thread options",
- items: [
- {
- type: "submenu" as const,
- title: "Environment",
- items: [
- {
- type: "action" as const,
- title: "All environments",
- state: props.selectedEnvironmentId === null ? ("on" as const) : ("off" as const),
- onPress: () => props.onEnvironmentChange(null),
- },
- ...props.environments.map((environment) => ({
- type: "action" as const,
- title: environment.label,
- state:
- props.selectedEnvironmentId === environment.environmentId
- ? ("on" as const)
- : ("off" as const),
- onPress: () => props.onEnvironmentChange(environment.environmentId),
- })),
- ],
- },
- {
- type: "submenu" as const,
- title: "Sort by archived date",
- items: [
- {
- type: "action" as const,
- title: "Newest first",
- state: props.sortOrder === "newest" ? ("on" as const) : ("off" as const),
- onPress: () => props.onSortOrderChange("newest"),
- },
- {
- type: "action" as const,
- title: "Oldest first",
- state: props.sortOrder === "oldest" ? ("on" as const) : ("off" as const),
- onPress: () => props.onSortOrderChange("oldest"),
- },
- ],
- },
- ],
- };
-
return (
- <>
- {/* Static header config (glass preset + title) lives in Stack.tsx; only
- dynamic toolbar/search wiring is set here. */}
- [
- createNativeMailSearchToolbarItem({
- composeButtonId: "archived-refresh",
- composeSystemImageName: "arrow.clockwise",
- filterMenu: archiveFilterMenu,
- filterButtonId: "archived-filter",
- filterSystemImageName: hasCustomFilter
- ? "line.3.horizontal.decrease.circle.fill"
- : "line.3.horizontal.decrease",
- onComposePress: props.onRefresh,
- onSearchTextChange: props.onSearchQueryChange,
- placeholder: "Search",
- searchTextChangeId: "archived-search-text",
- }),
- ]
- : undefined,
- headerSearchBarOptions: usesCompactMailToolbar
- ? undefined
- : {
- ...(usesNativeChrome
- ? {
- allowToolbarIntegration: true,
- // "integratedButton" is an iOS 26 search-bar placement;
- // pre-glass iOS keeps the default pull-down placement.
- ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
- ? { placement: "integratedButton" as const }
- : null),
- }
- : {
- placement: "stacked" as const,
- }),
- autoCapitalize: "none",
- hideNavigationBar: false,
- obscureBackground: false,
- placeholder: "Search archived threads",
- onChangeText: (event) => {
- props.onSearchQueryChange(event.nativeEvent.text);
+ navigation.goBack()}
+ search={{
+ value: props.searchQuery,
+ onChangeText: props.onSearchQueryChange,
+ placeholder: "Search archived threads",
+ compactPlaceholder: "Search",
+ mode: "inline",
+ compactToolbar: width < 700,
+ onRefresh: props.onRefresh,
+ refreshInToolbar: true,
+ refreshAccessibilityLabel: "Refresh archived threads",
+ }}
+ menus={[
+ {
+ title: "Archived thread options",
+ icon: hasCustomFilter
+ ? "line.3.horizontal.decrease.circle.fill"
+ : "line.3.horizontal.decrease.circle",
+ items: [
+ {
+ id: "environment",
+ title: "Environment",
+ items: [
+ {
+ id: "environment:all",
+ title: "All environments",
+ selected: props.selectedEnvironmentId === null,
+ onPress: () => props.onEnvironmentChange(null),
},
- onCancelButtonPress: () => {
- props.onSearchQueryChange("");
+ ...props.environments.map((environment) => ({
+ id: `environment:${environment.environmentId}`,
+ title: environment.label,
+ selected: props.selectedEnvironmentId === environment.environmentId,
+ onPress: () => props.onEnvironmentChange(environment.environmentId),
+ })),
+ ],
+ },
+ {
+ id: "sort",
+ title: "Sort by archived date",
+ items: [
+ {
+ id: "sort:newest",
+ title: "Newest first",
+ selected: props.sortOrder === "newest",
+ onPress: () => props.onSortOrderChange("newest"),
},
- },
- }}
- />
-
- {usesCompactMailToolbar ? null : (
-
- {usesNativeChrome ? (
-
- ) : null}
-
-
- Environment
- props.onEnvironmentChange(null)}
- >
- All environments
-
- {props.environments.map((environment) => (
- props.onEnvironmentChange(environment.environmentId)}
- >
- {environment.label}
-
- ))}
-
-
-
- Sort by archived date
- props.onSortOrderChange("newest")}
- >
- Newest first
-
- props.onSortOrderChange("oldest")}
- >
- Oldest first
-
-
-
-
- )}
- >
+ {
+ id: "sort:oldest",
+ title: "Oldest first",
+ selected: props.sortOrder === "oldest",
+ onPress: () => props.onSortOrderChange("oldest"),
+ },
+ ],
+ },
+ ],
+ },
+ ]}
+ />
);
}
+type ArchivedThreadListItem =
+ | {
+ readonly kind: "project";
+ readonly key: string;
+ readonly environmentLabel: string | null;
+ readonly environmentMachine: EnvironmentMachineKind;
+ readonly project: EnvironmentProject;
+ }
+ | {
+ readonly kind: "thread";
+ readonly key: string;
+ readonly environmentLabel: string | null;
+ readonly isFirst: boolean;
+ readonly isLast: boolean;
+ readonly thread: EnvironmentThreadShell;
+ };
+
function ProjectGroupLabel(props: {
readonly environmentLabel: string | null;
readonly environmentMachine: EnvironmentMachineKind;
From 88b9226d229a7d51283122a8b8b8be8305822a9e Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:16 -0700
Subject: [PATCH 028/111] refactor(mobile): compose review menus through the
shared header (#12400)
---
.../src/features/review/ReviewSheet.tsx | 332 ++++++------------
.../useReviewHeaderPresentation.android.ts | 20 ++
.../review/useReviewHeaderPresentation.tsx | 60 ++++
3 files changed, 190 insertions(+), 222 deletions(-)
create mode 100644 apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts
create mode 100644 apps/mobile/src/features/review/useReviewHeaderPresentation.tsx
diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx
index 0035f91e2d18..cb55570f9116 100644
--- a/apps/mobile/src/features/review/ReviewSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewSheet.tsx
@@ -1,11 +1,10 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
-import type { MenuAction } from "@react-native-menu/menu";
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
-import {
- NativeHeaderToolbar,
- NativeStackScreenOptions,
- nativeHeaderScrollEdgeEffects,
-} from "../../native/StackHeader";
+import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
+import { ScreenHeader } from "../../components/ScreenHeader";
+import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
+import type { ReviewSectionItem } from "./reviewModel";
+import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation";
import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
import {
memo,
@@ -33,8 +32,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
-import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
-import { ControlPillMenu } from "../../components/ControlPill";
+import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { cn } from "../../lib/cn";
import { environmentCatalog } from "../../connection/catalog";
@@ -50,17 +48,7 @@ import {
useAdaptiveWorkspacePaneRole,
useRegisterWorkspaceInspector,
} from "../layout/AdaptiveWorkspaceLayout";
-import { useEnvironmentQuery } from "../../state/query";
-import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
-import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
-import { useThreadSelection } from "../../state/use-thread-selection";
-import { vcsEnvironment } from "../../state/vcs";
-import {
- AndroidWorkspaceSidebarButton,
- WorkspaceSidebarToolbar,
-} from "../layout/workspace-sidebar-toolbar";
-import { ThreadGitMenu } from "../threads/ThreadGitControls";
import { useReviewCacheForThread } from "./reviewState";
import {
isNativeReviewDiffDrawEvent,
@@ -78,9 +66,96 @@ import { useReviewCommentSelectionController } from "./useReviewCommentSelection
import { resolveReviewAvailability } from "./reviewAvailability";
import { resolveSelectedReviewFileId } from "./reviewPaneSelection";
import { buildReviewSectionMenu } from "./review-section-menu";
-import type { ReviewSectionItem } from "./reviewModel";
import { reportShowcaseSceneRendered } from "../showcase/showcaseRenderSignal";
+function ReviewHeader(
+ props: Parameters[0] & {
+ readonly iconColor: string;
+ readonly sectionMenu: ReturnType;
+ readonly showSectionToolbar: boolean;
+ readonly showChangedFilesToggle: boolean;
+ readonly onSelectSection: (sectionId: string) => void;
+ readonly onReturnToThread: () => void;
+ },
+) {
+ const { panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
+ const presentation = useReviewHeaderPresentation(props);
+ const sectionAction = (
+ section: ReviewSectionItem | null,
+ title: string,
+ ): ScreenHeaderMenuItem => ({
+ id: section ? `section:${section.id}` : `unavailable:${title}`,
+ title,
+ disabled: section === null,
+ selected: section !== null && section.id === props.selectedSection?.id,
+ onPress: () => {
+ if (section) props.onSelectSection(section.id);
+ },
+ });
+ return (
+ 0
+ ? [
+ {
+ id: "turns",
+ title: "Turn",
+ items: props.sectionMenu.turns.map((section) => ({
+ id: `section:${section.id}`,
+ title: section.title,
+ subtitle: section.subtitle ?? undefined,
+ selected: section.id === props.selectedSection?.id,
+ onPress: () => props.onSelectSection(section.id),
+ })),
+ },
+ ]
+ : []),
+ ...(presentation.refreshAction ? [presentation.refreshAction] : []),
+ ],
+ },
+ ]
+ : []),
+ ]}
+ />
+ );
+}
+
const REVIEW_HEADER_SPACING = 0;
const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
@@ -370,10 +445,9 @@ type ReviewSheetProps = StaticScreenProps<{
}>;
export function ReviewSheet(props: ReviewSheetProps) {
- const isAndroid = Platform.OS === "android";
const { nativeReviewDiffStyle } = useAppearanceCodeSurface();
useAdaptiveWorkspacePaneRole("inspector");
- const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout();
+ const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { themeAppearance: selectedTheme } = useAppearancePreferences();
@@ -384,23 +458,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
const isEnvironmentReady = environment.presentation?.connection.phase === "connected";
const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
const reviewCache = useReviewCacheForThread({ environmentId, threadId });
- /* ─── Git actions for the toolbar menu (commit/push without leaving review) ── */
- const { selectedThread } = useThreadSelection();
const { selectedThreadCwd } = useSelectedThreadWorktree();
- const gitState = useSelectedThreadGitState();
- const gitActions = useSelectedThreadGitActions();
- const gitStatusQuery = useEnvironmentQuery(
- selectedThread !== null && selectedThreadCwd !== null
- ? vcsEnvironment.status({
- environmentId: selectedThread.environmentId,
- input: { cwd: selectedThreadCwd },
- })
- : null,
- );
- // The selection-based git hooks only apply when this review belongs to the
- // selected thread (it always does when reached from the thread's toolbar).
- const gitMenuAvailable =
- selectedThread !== null && String(selectedThread.id) === String(threadId);
// With a solid (non-overlay) header the content lays out below the header
// natively, so no manual top inset is needed. (Android renders its own
// in-flow AndroidScreenHeader, so it needs no inset either.)
@@ -581,52 +639,6 @@ export function ReviewSheet(props: ReviewSheetProps) {
hasCachedSelectedDiff,
hasAnyCachedDiff,
});
- const androidSectionMenuActions = useMemo(() => {
- const sectionAction = (section: ReviewSectionItem | null, title: string): MenuAction => ({
- id: section ? `section:${section.id}` : `unavailable:${title}`,
- title: section?.id === selectedSection?.id ? `${title} (selected)` : title,
- attributes: section ? undefined : { disabled: true },
- });
- const actions: MenuAction[] = [
- sectionAction(sectionMenu.workingTree, "Working tree"),
- sectionAction(sectionMenu.branchChanges, "Branch changes"),
- sectionAction(sectionMenu.latestTurn, "Latest turn"),
- ];
-
- if (sectionMenu.turns.length > 0) {
- actions.push({
- id: "turns",
- title: "Turn",
- subactions: sectionMenu.turns.map((section) => ({
- id: `section:${section.id}`,
- title: section.id === selectedSection?.id ? `${section.title} (selected)` : section.title,
- subtitle: section.subtitle ?? undefined,
- })),
- });
- }
-
- // The Android native diff surface has no pull-to-refresh, so refresh
- // stays a menu action there (iOS refreshes via pull-to-refresh instead).
- actions.push({
- id: "refresh",
- title: "Refresh current diff",
- attributes: {
- disabled: !selectedSection || selectedSection.isLoading,
- },
- });
- return actions;
- }, [sectionMenu, selectedSection]);
- const handleAndroidSectionMenuAction = useCallback(
- (event: { nativeEvent: { event: string } }) => {
- const id = event.nativeEvent.event;
- if (id === "refresh") {
- void handlePullToRefresh();
- } else if (id.startsWith("section:")) {
- selectSection(id.slice("section:".length));
- }
- },
- [handlePullToRefresh, selectSection],
- );
const handleRetryEnvironment = useCallback(() => {
void retryEnvironment(environmentId);
}, [environmentId, retryEnvironment]);
@@ -703,147 +715,23 @@ export function ReviewSheet(props: ReviewSheetProps) {
return (
<>
- 0 ? headerSubtitle : undefined,
- }
- }
+
- {isAndroid ? (
- }
- hideBottomBorder
- subtitle={androidHeaderSubtitle || "Select a diff"}
- onBack={handleReturnToThread}
- trailing={
- <>
- {showChangedFilesToggle ? (
-
- ) : null}
- {showSectionToolbar ? (
-
-
-
- ) : null}
- >
- }
- />
- ) : null}
-
-
-
-
-
- {!isAndroid && (showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable) ? (
-
- {showChangedFilesToggle ? (
-
- ) : null}
- {gitMenuAvailable && selectedThread !== null ? (
-
- ) : null}
- {showSectionToolbar ? (
-
-
- {
- if (sectionMenu.workingTree) {
- selectSection(sectionMenu.workingTree.id);
- }
- }}
- >
- Working tree
-
- {
- if (sectionMenu.branchChanges) {
- selectSection(sectionMenu.branchChanges.id);
- }
- }}
- >
- Branch changes
-
- {
- if (sectionMenu.latestTurn) {
- selectSection(sectionMenu.latestTurn.id);
- }
- }}
- >
- Latest turn
-
- {sectionMenu.turns.length > 0 ? (
-
- {sectionMenu.turns.map((section) => (
- selectSection(section.id)}
- subtitle={section.subtitle ?? undefined}
- >
- {section.title}
-
- ))}
-
- ) : null}
-
-
- ) : null}
-
- ) : null}
-
{showConnectionNotice ? (
diff --git a/apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts b/apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts
new file mode 100644
index 000000000000..914b3545b86a
--- /dev/null
+++ b/apps/mobile/src/features/review/useReviewHeaderPresentation.android.ts
@@ -0,0 +1,20 @@
+import type { useReviewHeaderPresentation as useIosReviewHeaderPresentation } from "./useReviewHeaderPresentation";
+
+export function useReviewHeaderPresentation(
+ props: Parameters[0],
+): ReturnType {
+ return {
+ title: "Review changes",
+ subtitle: props.androidSubtitle || "Select a diff",
+ gitMenu: null,
+ menuIcon: "ellipsis.circle",
+ refreshAction: {
+ id: "refresh",
+ title: "Refresh current diff",
+ disabled: !props.selectedSection || props.selectedSection.isLoading,
+ onPress: () => {
+ void props.onRefresh();
+ },
+ },
+ };
+}
diff --git a/apps/mobile/src/features/review/useReviewHeaderPresentation.tsx b/apps/mobile/src/features/review/useReviewHeaderPresentation.tsx
new file mode 100644
index 000000000000..f9ab9d794a47
--- /dev/null
+++ b/apps/mobile/src/features/review/useReviewHeaderPresentation.tsx
@@ -0,0 +1,60 @@
+import { useEnvironmentQuery } from "../../state/query";
+import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
+import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
+import { useThreadSelection } from "../../state/use-thread-selection";
+import { vcsEnvironment } from "../../state/vcs";
+import { useThreadGitMenuDefinition } from "../threads/ThreadGitControls";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import type { ReviewSectionItem } from "./reviewModel";
+import type { ScreenHeaderMenuItem, ScreenHeaderMenu } from "../../components/ScreenHeader.types";
+import type { AppSymbolName } from "../../components/AppSymbol";
+
+interface ReviewHeaderPresentation {
+ readonly title: string;
+ readonly subtitle: string;
+ readonly gitMenu: ScreenHeaderMenu | null;
+ readonly menuIcon: AppSymbolName;
+ readonly refreshAction?: ScreenHeaderMenuItem;
+}
+
+export function useReviewHeaderPresentation(props: {
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly title: string;
+ readonly subtitle: string;
+ readonly androidSubtitle: string;
+ readonly selectedThreadCwd: string | null;
+ readonly selectedSection: ReviewSectionItem | null;
+ readonly onRefresh: () => Promise;
+}): ReviewHeaderPresentation {
+ const { selectedThread } = useThreadSelection();
+ const gitState = useSelectedThreadGitState();
+ const gitActions = useSelectedThreadGitActions();
+ const gitStatusQuery = useEnvironmentQuery(
+ selectedThread !== null && props.selectedThreadCwd !== null
+ ? vcsEnvironment.status({
+ environmentId: selectedThread.environmentId,
+ input: { cwd: props.selectedThreadCwd },
+ })
+ : null,
+ );
+ // The selection-based git hooks only apply when this review belongs to the
+ // selected thread (it always does when reached from the thread's toolbar).
+ const gitMenuAvailable =
+ selectedThread !== null && String(selectedThread.id) === String(props.threadId);
+ const gitMenu = useThreadGitMenuDefinition({
+ environmentId: props.environmentId,
+ threadId: props.threadId,
+ currentBranch: selectedThread?.branch ?? null,
+ gitStatus: gitStatusQuery.data,
+ gitOperationLabel: gitState.gitOperationLabel,
+ onPull: gitActions.onPullSelectedThreadBranch,
+ onRunAction: gitActions.onRunSelectedThreadGitAction,
+ });
+ return {
+ title: props.title,
+ subtitle: props.subtitle,
+ menuIcon: "ellipsis",
+ gitMenu: gitMenuAvailable ? gitMenu : null,
+ };
+}
From 93e04160a0c0dece8a258384d2118a660415a53d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 12:55:17 -0700
Subject: [PATCH 029/111] feat(mobile): search projects when starting a task
(#12496)
---
.../features/threads/NewTaskRouteScreen.tsx | 126 ++++++++++--------
.../new-task-project-selection.test.ts | 29 ++++
.../threads/new-task-project-selection.ts | 17 +++
3 files changed, 116 insertions(+), 56 deletions(-)
diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
index 4b987bbb1423..75210bc36331 100644
--- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
@@ -1,5 +1,5 @@
import { MaterialListRow } from "../../components/MaterialListRow";
-import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
+import { ScreenHeader } from "../../components/ScreenHeader";
import {
StackActions,
useIsFocused,
@@ -8,11 +8,10 @@ import {
} from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { cn } from "../../lib/cn";
-import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { MaterialScreenContent } from "../../components/MaterialScreenContent";
import { MaterialButton } from "../../components/MaterialButton";
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
@@ -24,7 +23,7 @@ import { useWorkspaceState } from "../../state/workspace";
import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
import { useIncomingShare } from "../sharing/IncomingShareProvider";
import { useNewTaskFlow } from "./new-task-flow-provider";
-import { getProjectScopeSelectionTarget } from "./new-task-project-selection";
+import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
type NewTaskRouteParams = {
readonly incomingShareId?: string | string[];
@@ -85,13 +84,58 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
};
}
+function NewTaskHeader(props: {
+ readonly title: string;
+ readonly subtitle: string | null;
+ readonly canAddProject: boolean;
+ readonly searchText: string;
+ readonly onSearchTextChange: (text: string) => void;
+}) {
+ const navigation = useNavigation();
+ const { layout } = useAdaptiveWorkspaceLayout();
+ return (
+ navigation.goBack()}
+ actions={[
+ ...(Platform.OS === "ios" && layout.usesSplitView
+ ? [
+ {
+ accessibilityLabel: "Close new task",
+ icon: "xmark" as const,
+ onPress: () => navigation.goBack(),
+ },
+ ]
+ : []),
+ ...(props.canAddProject
+ ? [
+ {
+ accessibilityLabel: "Add project",
+ icon: "plus" as const,
+ onPress: () => navigation.dispatch(StackActions.push("AddProject")),
+ },
+ ]
+ : []),
+ ]}
+ search={{
+ value: props.searchText,
+ onChangeText: props.onSearchTextChange,
+ placeholder: "Search projects",
+ }}
+ />
+ );
+}
+
export function NewTaskRouteScreen({ route }: StaticScreenProps) {
const projects = useProjects();
+ const [searchText, setSearchText] = useState("");
const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow();
const { state: catalogState } = useWorkspaceState();
const navigation = useNavigation();
const isFocused = useIsFocused();
- const { layout } = useAdaptiveWorkspaceLayout();
const insets = useSafeAreaInsets();
const { getShare, releaseShareReservation } = useIncomingShare();
const routeShareId = Array.isArray(route.params?.incomingShareId)
@@ -107,6 +151,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps(null);
const reservedDestinationProject = incomingShare?.destination
? (projects.find(
@@ -180,67 +225,27 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps
- {Platform.OS === "android" ? (
- <>
- {/* Android renders its own in-screen header instead of the native bar. */}
-
- navigation.goBack()}
- actions={
- catalogState.hasReadyEnvironment
- ? [
- {
- accessibilityLabel: "Add project",
- icon: "plus",
- onPress: () => navigation.dispatch(StackActions.push("AddProject")),
- },
- ]
- : []
- }
- />
- >
- ) : (
- <>
-
-
- {layout.usesSplitView ? (
- navigation.goBack()}
- separateBackground
- />
- ) : null}
- {catalogState.hasReadyEnvironment ? (
- navigation.dispatch(StackActions.push("AddProject"))}
- separateBackground
- />
- ) : null}
-
- >
- )}
+
)}
+ ) : visibleScopes.length === 0 ? (
+
+
+ No matching projects
+
+
+ Try a different project name or workspace path.
+
+
) : (
- {projectScopes.map((scope, scopeIndex) => {
+ {visibleScopes.map((scope, scopeIndex) => {
const hasMultipleProjects = scope.projects.length > 1;
const selectionTarget = getProjectScopeSelectionTarget(
scope,
diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts
index ca59a2b9dddc..12f669530aae 100644
--- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts
+++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import type { HomeProjectScope } from "../home/homeThreadList";
import {
+ filterProjectScopes,
getProjectScopeSelectionTarget,
resolveDraftProjectSelection,
resolveEnvironmentProjectMatch,
@@ -152,3 +153,31 @@ describe("resolveDraftProjectSelection", () => {
});
});
});
+
+describe("filterProjectScopes", () => {
+ const mac = makeProject("code", "mac", { title: "Desktop checkout" });
+ const server = makeProject("remote-code", "server", { workspaceRoot: "/srv/remote-workspace" });
+ const code = makeScope([mac, server]);
+ const docs = { ...makeScope([makeProject("docs")]), key: "docs", title: "Documentation" };
+ const scopes = [code, docs];
+
+ it("keeps all projects for an empty or whitespace-only query", () => {
+ expect(filterProjectScopes(scopes, "")).toBe(scopes);
+ expect(filterProjectScopes(scopes, " ")).toBe(scopes);
+ });
+
+ it("matches logical names and workspace names or paths without case sensitivity", () => {
+ expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]);
+ expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]);
+ expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]);
+ expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]);
+ expect(filterProjectScopes(scopes, "missing-project")).toEqual([]);
+ });
+
+ it("preserves the whole logical project and preferred environment when a workspace matches", () => {
+ const matches = filterProjectScopes(scopes, "REMOTE-WORKSPACE");
+ expect(matches[0]).toBe(code);
+ expect(getProjectScopeSelectionTarget(matches[0]!, EnvironmentId.make("mac"))).toBe(mac);
+ expect(code.projects).toEqual([mac, server]);
+ });
+});
diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts
index 65dd9916f2f6..27d1303c3a45 100644
--- a/apps/mobile/src/features/threads/new-task-project-selection.ts
+++ b/apps/mobile/src/features/threads/new-task-project-selection.ts
@@ -19,6 +19,23 @@ export function getProjectScopeSelectionTarget(
);
}
+export function filterProjectScopes(
+ scopes: ReadonlyArray,
+ searchText: string,
+): ReadonlyArray {
+ const query = searchText.trim().toLowerCase();
+ if (!query) return scopes;
+ return scopes.filter(
+ (scope) =>
+ scope.title.toLowerCase().includes(query) ||
+ scope.projects.some(
+ (project) =>
+ project.title.toLowerCase().includes(query) ||
+ project.workspaceRoot.toLowerCase().includes(query),
+ ),
+ );
+}
+
function getOnlySelectableProject(
projectScopes: ReadonlyArray,
): EnvironmentProject | null {
From 82cd1d1aabe8baa4a5ae23f3c7b08b3fce092e08 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 14:23:09 -0700
Subject: [PATCH 030/111] fix(mobile): preserve multiple model favorites
(#12505)
---
.../features/threads/ThreadSettingsSheet.tsx | 15 +++---
apps/mobile/src/state/preferences.test.ts | 53 ++++++++++++++++---
apps/mobile/src/state/preferences.ts | 14 +++--
3 files changed, 63 insertions(+), 19 deletions(-)
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index 9dbefe6018b7..b8411229a8ac 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -47,7 +47,6 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
import { applyProviderOptionSelection } from "../../lib/providerOptions";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
-import type { Preferences } from "../../persistence/mobile-preferences";
import {
NativeHeaderToolbar,
NativeStackScreenOptions,
@@ -331,12 +330,14 @@ function ThreadSettingsSessionProvider(
(option: ModelOption) => {
if (!favoritesLoaded) return;
void Haptics.selectionAsync();
- savePreferences((current: Preferences) => ({
- modelFavorites: toggleModelFavorite(
- current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
- option,
- ),
- }));
+ savePreferences({
+ transform: (current) => ({
+ modelFavorites: toggleModelFavorite(
+ current.modelFavorites ?? EMPTY_MODEL_FAVORITES,
+ option,
+ ),
+ }),
+ });
},
[favoritesLoaded, savePreferences],
);
diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts
index 48b7badcc2c2..9a84d70cd61a 100644
--- a/apps/mobile/src/state/preferences.test.ts
+++ b/apps/mobile/src/state/preferences.test.ts
@@ -5,6 +5,9 @@ import * as Option from "effect/Option";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
import { vi } from "vite-plus/test";
import { ProviderInstanceId } from "@t3tools/contracts";
+import { RegistryContext, useAtomSet } from "@effect/atom-react";
+import { createElement } from "react";
+import { renderToString } from "react-dom/server";
vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
@@ -125,12 +128,16 @@ describe("mobile preferences state", () => {
}),
);
- it.effect("keeps both favorites when updates are sent before a render", () =>
+ it.effect("keeps both favorites when the React setter sends updates before a render", () =>
Effect.gen(function* () {
let persisted: Preferences = { modelFavorites: [] };
const state = makePreferencesState({
load: Effect.succeed(persisted),
- savePatch: () => Effect.die(new Error("Favorite updates must use a transform.")),
+ savePatch: (patch) =>
+ Effect.sync(() => {
+ persisted = { ...persisted, ...patch };
+ return persisted;
+ }),
update: (transform) =>
Effect.sync(() => {
persisted = { ...persisted, ...transform(persisted) };
@@ -142,13 +149,31 @@ describe("mobile preferences state", () => {
const unmountUpdate = registry.mount(state.updatePreferencesAtom);
yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });
+ function useSavePreferences() {
+ return useAtomSet(state.updatePreferencesAtom);
+ }
+ const setters: Array> = [];
+ function CaptureSetter() {
+ setters.push(useSavePreferences());
+ return null;
+ }
+ // Exercise the real React setter, which treats bare functions as updates
+ // to the atom's read value. Direct registry.set calls bypass that behavior.
+ renderToString(
+ createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)),
+ );
+ const savePreferences = setters[0]!;
const provider = ProviderInstanceId.make("codex");
- registry.set(state.updatePreferencesAtom, (current) => ({
- modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
- }));
- registry.set(state.updatePreferencesAtom, (current) => ({
- modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
- }));
+ savePreferences({
+ transform: (current) => ({
+ modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
+ }),
+ });
+ savePreferences({
+ transform: (current) => ({
+ modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
+ }),
+ });
yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
suspendOnWaiting: true,
});
@@ -158,6 +183,18 @@ describe("mobile preferences state", () => {
{ provider, model: "sol" },
]);
+ savePreferences({
+ transform: (current) => ({
+ modelFavorites: (current.modelFavorites ?? []).filter(
+ (favorite) => favorite.model !== "astra",
+ ),
+ }),
+ });
+ yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
+ suspendOnWaiting: true,
+ });
+ expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]);
+
unmountUpdate();
unmountPreferences();
registry.dispose();
diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts
index bcecc69db458..7baff0251c15 100644
--- a/apps/mobile/src/state/preferences.ts
+++ b/apps/mobile/src/state/preferences.ts
@@ -15,7 +15,11 @@ interface OptimisticPreferences {
readonly versions: Partial>;
}
-type PreferencesUpdate = Partial | ((current: Preferences) => Partial);
+// A bare function is interpreted by useAtomSet as an update to the command's
+// AsyncResult, not as a preference transform. Keep transforms inside a payload.
+type PreferencesUpdate =
+ | Partial
+ | { readonly transform: (current: Preferences) => Partial };
/**
* Owns the device preference blob for the lifetime of the app registry.
@@ -62,8 +66,10 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime {
const currentPreferences = get(preferencesAtom);
const patch =
- typeof update === "function"
- ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {})
+ "transform" in update
+ ? update.transform(
+ AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},
+ )
: update;
const version = ++nextPatchVersion;
const current = get(optimisticPatchAtom);
@@ -77,7 +83,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime
- typeof update === "function" ? store.update(update) : store.savePatch(patch),
+ "transform" in update ? store.update(update.transform) : store.savePatch(patch),
),
Effect.tap((saved) =>
Effect.sync(() => {
From 3bb06ad910389fcfc886bd5156f41dc73f307f80 Mon Sep 17 00:00:00 2001
From: Yordis Prieto
Date: Fri, 18 Sep 2026 18:55:15 -0400
Subject: [PATCH 031/111] feat(server): export log records over OTLP (#12493)
Signed-off-by: Yordis Prieto
---
apps/desktop/src/app/DesktopConfig.ts | 2 +
.../src/app/DesktopEnvironment.test.ts | 4 +
apps/desktop/src/app/DesktopEnvironment.ts | 4 +
.../DesktopBackendConfiguration.test.ts | 103 +++++++++
.../backend/DesktopBackendConfiguration.ts | 25 ++-
apps/server/src/bin.test.ts | 1 +
apps/server/src/cli/config.test.ts | 44 ++++
apps/server/src/cli/config.ts | 8 +-
apps/server/src/cli/pair.ts | 1 +
apps/server/src/config.ts | 14 ++
.../src/environment/ServerEnvironment.test.ts | 1 +
.../src/observability/Layers/Observability.ts | 17 +-
apps/server/src/server.test.ts | 4 +
apps/server/src/serverLogger.test.ts | 197 ++++++++++++++++++
apps/server/src/serverLogger.ts | 42 +++-
apps/server/src/serverSettings.test.ts | 2 +
apps/server/src/ws.ts | 2 +
docs/operations/observability.md | 19 +-
.../client-runtime/src/rpc/session.test.ts | 1 +
packages/contracts/src/desktopBootstrap.ts | 1 +
packages/contracts/src/server.test.ts | 17 ++
packages/contracts/src/server.ts | 4 +
packages/contracts/src/settings.ts | 2 +
packages/shared/src/serverSettings.test.ts | 9 +-
packages/shared/src/serverSettings.ts | 5 +-
25 files changed, 503 insertions(+), 26 deletions(-)
create mode 100644 apps/server/src/serverLogger.test.ts
diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts
index d03dec70ce82..773ea24509cf 100644
--- a/apps/desktop/src/app/DesktopConfig.ts
+++ b/apps/desktop/src/app/DesktopConfig.ts
@@ -46,6 +46,8 @@ export const DesktopConfig = Config.all({
desktopLanHostOverride: trimmedString("T3CODE_DESKTOP_LAN_HOST"),
desktopHttpsEndpointUrls: commaSeparatedStrings("T3CODE_DESKTOP_HTTPS_ENDPOINTS"),
otlpTracesUrl: trimmedString("T3CODE_OTLP_TRACES_URL"),
+ otlpMetricsUrl: trimmedString("T3CODE_OTLP_METRICS_URL"),
+ otlpLogsUrl: trimmedString("T3CODE_OTLP_LOGS_URL"),
otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
Config.withDefault(10_000),
),
diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts
index 0e5fbecd0224..b5aabf253d0f 100644
--- a/apps/desktop/src/app/DesktopEnvironment.test.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.test.ts
@@ -51,6 +51,8 @@ describe("DesktopEnvironment", () => {
VITE_DEV_SERVER_URL: "http://localhost:5173",
T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH: " /remote/server.mjs ",
T3CODE_OTLP_TRACES_URL: " http://127.0.0.1:4318/v1/traces ",
+ T3CODE_OTLP_METRICS_URL: " http://127.0.0.1:4318/v1/metrics ",
+ T3CODE_OTLP_LOGS_URL: " http://127.0.0.1:4318/v1/logs ",
T3CODE_OTLP_EXPORT_INTERVAL_MS: "2500",
T3CODE_OTLP_HEADERS: "authorization=Basic%20abc%3D%3D,x-tenant=t3",
T3CODE_OTLP_PROTOCOL: "http/protobuf",
@@ -86,6 +88,8 @@ describe("DesktopEnvironment", () => {
assert.deepEqual(environment.configuredBackendPort, Option.some(4949));
assert.deepEqual(environment.commitHashOverride, Option.some("0123456789abcdef"));
assert.deepEqual(environment.otlpTracesUrl, Option.some("http://127.0.0.1:4318/v1/traces"));
+ assert.deepEqual(environment.otlpMetricsUrl, Option.some("http://127.0.0.1:4318/v1/metrics"));
+ assert.deepEqual(environment.otlpLogsUrl, Option.some("http://127.0.0.1:4318/v1/logs"));
assert.equal(environment.otlpExportIntervalMs, 2500);
assert.deepEqual(
environment.otlpHeaders,
diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts
index e7a489d5e89d..d6924b08acec 100644
--- a/apps/desktop/src/app/DesktopEnvironment.ts
+++ b/apps/desktop/src/app/DesktopEnvironment.ts
@@ -72,6 +72,8 @@ export class DesktopEnvironment extends Context.Service<
readonly configuredBackendPort: Option.Option;
readonly commitHashOverride: Option.Option;
readonly otlpTracesUrl: Option.Option;
+ readonly otlpMetricsUrl: Option.Option;
+ readonly otlpLogsUrl: Option.Option;
readonly otlpExportIntervalMs: number;
readonly otlpHeaders: Option.Option>;
readonly otlpProtocol: OtlpProtocol;
@@ -227,6 +229,8 @@ const make = Effect.fn("desktop.environment.make")(function* (
configuredBackendPort: config.configuredBackendPort,
commitHashOverride: config.commitHashOverride,
otlpTracesUrl: config.otlpTracesUrl,
+ otlpMetricsUrl: config.otlpMetricsUrl,
+ otlpLogsUrl: config.otlpLogsUrl,
otlpExportIntervalMs: config.otlpExportIntervalMs,
otlpHeaders: config.otlpHeaders,
otlpProtocol: config.otlpProtocol,
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
index 595582a16280..41eeedc668b6 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
@@ -23,6 +23,7 @@ const PersistedServerObservabilitySettingsDocument = Schema.Struct({
observability: Schema.Struct({
otlpTracesUrl: Schema.String,
otlpMetricsUrl: Schema.String,
+ otlpLogsUrl: Schema.String,
}),
});
@@ -60,6 +61,9 @@ function makeEnvironmentLayer(
readonly resourcesPath?: string;
readonly appVersion?: string;
readonly processArch?: NodeJS.Architecture;
+ readonly otlpTracesUrl?: string;
+ readonly otlpMetricsUrl?: string;
+ readonly otlpLogsUrl?: string;
},
) {
return DesktopEnvironment.layer({
@@ -82,6 +86,9 @@ function makeEnvironmentLayer(
T3CODE_MODE: "desktop",
T3CODE_DESKTOP_LAN_HOST: "192.168.1.50",
VITE_DEV_SERVER_URL: options?.devServerUrl,
+ T3CODE_OTLP_TRACES_URL: options?.otlpTracesUrl,
+ T3CODE_OTLP_METRICS_URL: options?.otlpMetricsUrl,
+ T3CODE_OTLP_LOGS_URL: options?.otlpLogsUrl,
}),
),
),
@@ -730,6 +737,7 @@ describe("DesktopBackendConfiguration", () => {
observability: {
otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ",
otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ",
+ otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ",
},
}),
);
@@ -737,6 +745,7 @@ describe("DesktopBackendConfiguration", () => {
const config = yield* configuration.resolvePrimary;
assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
+ assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs");
}),
),
);
@@ -749,10 +758,103 @@ describe("DesktopBackendConfiguration", () => {
assert.isUndefined(config.bootstrap.otlpTracesUrl);
assert.isUndefined(config.bootstrap.otlpMetricsUrl);
+ assert.isUndefined(config.bootstrap.otlpLogsUrl);
}),
),
);
+ it.effect("resolveWsl carries environment-configured observability endpoints", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-backend-config-test-",
+ });
+
+ yield* Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5050, distro: null });
+
+ // No settings.json exists here: the endpoints come from the desktop
+ // process's env, which a WSL child cannot inherit, so the bootstrap
+ // has to carry them or log export stays off inside the distro.
+ assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces");
+ assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics");
+ assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs");
+ assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL");
+ }).pipe(
+ Effect.provide(
+ DesktopBackendConfiguration.layer.pipe(
+ Layer.provideMerge(serverExposureLayer),
+ Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
+ Layer.provideMerge(
+ DesktopWslEnvironment.layerTest({
+ isAvailable: true,
+ windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"),
+ getDistroIp: () => Option.some("172.27.0.99"),
+ }),
+ ),
+ Layer.provideMerge(
+ makeEnvironmentLayer(baseDir, {
+ platform: "win32",
+ otlpTracesUrl: " http://127.0.0.1:4318/v1/traces ",
+ otlpMetricsUrl: " http://127.0.0.1:4318/v1/metrics ",
+ otlpLogsUrl: " http://127.0.0.1:4318/v1/logs ",
+ }),
+ ),
+ ),
+ ),
+ );
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("environment observability endpoints win over the persisted settings file", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-backend-config-test-",
+ });
+
+ yield* Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+
+ yield* fileSystem.makeDirectory(environment.path.dirname(environment.serverSettingsPath), {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(
+ environment.serverSettingsPath,
+ yield* encodePersistedServerObservabilitySettingsDocument({
+ observability: {
+ otlpTracesUrl: "http://persisted:4318/v1/traces",
+ otlpMetricsUrl: "http://persisted:4318/v1/metrics",
+ otlpLogsUrl: "http://persisted:4318/v1/logs",
+ },
+ }),
+ );
+
+ const config = yield* configuration.resolvePrimary;
+ assert.equal(config.bootstrap.otlpLogsUrl, "http://env:4318/v1/logs");
+ // Only the logs endpoint is set in env, so the other two still come
+ // from the settings file rather than being dropped together.
+ assert.equal(config.bootstrap.otlpTracesUrl, "http://persisted:4318/v1/traces");
+ assert.equal(config.bootstrap.otlpMetricsUrl, "http://persisted:4318/v1/metrics");
+ }).pipe(
+ Effect.provide(
+ DesktopBackendConfiguration.layer.pipe(
+ Layer.provideMerge(serverExposureLayer),
+ Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(DesktopWslServerTree.layerTest()),
+ Layer.provideMerge(DesktopWslEnvironment.layerTest()),
+ Layer.provideMerge(
+ makeEnvironmentLayer(baseDir, { otlpLogsUrl: "http://env:4318/v1/logs" }),
+ ),
+ ),
+ ),
+ );
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
it.effect("logs structured context when persisted observability settings cannot be read", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
@@ -799,6 +901,7 @@ describe("DesktopBackendConfiguration", () => {
assert.isUndefined(config.bootstrap.otlpTracesUrl);
assert.isUndefined(config.bootstrap.otlpMetricsUrl);
+ assert.isUndefined(config.bootstrap.otlpLogsUrl);
const error = messages
.flatMap((message) => (Array.isArray(message) ? message : [message]))
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
index a36902304809..9e809667ac42 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
@@ -67,11 +67,13 @@ export class DesktopBackendConfiguration extends Context.Service<
interface BackendObservabilitySettings {
readonly otlpTracesUrl: Option.Option;
readonly otlpMetricsUrl: Option.Option;
+ readonly otlpLogsUrl: Option.Option;
}
const emptyBackendObservabilitySettings: BackendObservabilitySettings = {
otlpTracesUrl: Option.none(),
otlpMetricsUrl: Option.none(),
+ otlpLogsUrl: Option.none(),
};
const DESKTOP_BACKEND_ENV_NAMES = [
@@ -212,9 +214,26 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () {
return {
otlpTracesUrl: Option.fromNullishOr(parsed.otlpTracesUrl),
otlpMetricsUrl: Option.fromNullishOr(parsed.otlpMetricsUrl),
+ otlpLogsUrl: Option.fromNullishOr(parsed.otlpLogsUrl),
};
});
+// The bootstrap is the only channel that carries an OTLP endpoint to every
+// backend. A Windows-native child inherits the desktop process's env, but a
+// WSL child gets nothing across wsl.exe that WSLENV does not declare, and
+// WSLENV translation of URL-shaped values is unreliable, so the endpoints are
+// deliberately not forwarded that way. Env beats the persisted settings file,
+// matching the precedence resolveServerConfig and DesktopObservability apply.
+const readBackendObservabilitySettings = Effect.gen(function* () {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const persisted = yield* readPersistedBackendObservabilitySettings;
+ return {
+ otlpTracesUrl: Option.orElse(environment.otlpTracesUrl, () => persisted.otlpTracesUrl),
+ otlpMetricsUrl: Option.orElse(environment.otlpMetricsUrl, () => persisted.otlpMetricsUrl),
+ otlpLogsUrl: Option.orElse(environment.otlpLogsUrl, () => persisted.otlpLogsUrl),
+ } satisfies BackendObservabilitySettings;
+});
+
interface SharedBootstrapInput {
readonly bootstrapToken: string;
readonly observabilitySettings: BackendObservabilitySettings;
@@ -488,6 +507,10 @@ const buildObservabilityFragment = (observabilitySettings: BackendObservabilityS
onNone: () => ({}),
onSome: (otlpMetricsUrl) => ({ otlpMetricsUrl }),
}),
+ ...Option.match(observabilitySettings.otlpLogsUrl, {
+ onNone: () => ({}),
+ onSome: (otlpLogsUrl) => ({ otlpLogsUrl }),
+ }),
});
const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolvePrimary")(
@@ -804,7 +827,7 @@ export const make = Effect.gen(function* () {
// restart cycle without having to bounce the desktop process.
const sharedInputs = Effect.gen(function* () {
const bootstrapToken = yield* getOrCreateBootstrapToken;
- const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe(
+ const observabilitySettings = yield* readBackendObservabilitySettings.pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment),
);
diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts
index c1bea133f718..795bb05db148 100644
--- a/apps/server/src/bin.test.ts
+++ b/apps/server/src/bin.test.ts
@@ -100,6 +100,7 @@ const makeCliTestServerConfig = (baseDir: string) =>
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otlpHeaders: undefined,
diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts
index 8c4dbbcdbbf2..aea753c3398f 100644
--- a/apps/server/src/cli/config.test.ts
+++ b/apps/server/src/cli/config.test.ts
@@ -50,6 +50,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otlpHeaders: undefined,
@@ -398,6 +399,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
tailscaleServePort: 443,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
}),
);
const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
@@ -438,6 +440,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
...defaultObservabilityConfig,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
mode: "desktop",
port: 4888,
cwd: process.cwd(),
@@ -604,6 +607,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
observability: {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
},
})}\n`,
);
@@ -635,11 +639,13 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics");
+ expect(resolved.otlpLogsUrl).toBe("http://localhost:4318/v1/logs");
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
mode: "desktop",
port: 4888,
cwd: process.cwd(),
@@ -845,4 +851,42 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved.otlpProtocol).toBe("http/protobuf");
}),
);
+
+ it.effect("reads the OTLP logs URL from env", () =>
+ Effect.gen(function* () {
+ const { join } = yield* Path.Path;
+ const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-otlp-logs-url-base");
+
+ const resolved = yield* resolveServerConfig(
+ {
+ mode: Option.some("web"),
+ port: Option.some(3773),
+ host: Option.none(),
+ baseDir: Option.some(baseDir),
+ cwd: Option.none(),
+ devUrl: Option.none(),
+ noBrowser: Option.none(),
+ bootstrapFd: Option.none(),
+ autoBootstrapProjectFromCwd: Option.none(),
+ logWebSocketEvents: Option.none(),
+ tailscaleServeEnabled: Option.none(),
+ tailscaleServePort: Option.none(),
+ },
+ Option.none(),
+ ).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({
+ env: { T3CODE_OTLP_LOGS_URL: "http://collector.internal:4318/v1/logs" },
+ }),
+ ),
+ NetService.layer,
+ ),
+ ),
+ );
+
+ expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs");
+ }),
+ );
});
diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts
index 6db6abbcc8a1..32f465b6fb0a 100644
--- a/apps/server/src/cli/config.ts
+++ b/apps/server/src/cli/config.ts
@@ -96,6 +96,10 @@ const EnvServerConfig = Config.all({
Config.option,
Config.map(Option.getOrUndefined),
),
+ otlpLogsUrl: Config.String("T3CODE_OTLP_LOGS_URL").pipe(
+ Config.option,
+ Config.map(Option.getOrUndefined),
+ ),
otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe(
Config.withDefault(10_000),
),
@@ -227,7 +231,7 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st
const fs = yield* FileSystem.FileSystem;
const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false));
if (!exists) {
- return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
+ return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined };
}
const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => ""));
@@ -393,6 +397,8 @@ export const resolveServerConfig = (
env.otlpMetricsUrl ??
bootstrap?.otlpMetricsUrl ??
persistedObservabilitySettings.otlpMetricsUrl,
+ otlpLogsUrl:
+ env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl,
otlpExportIntervalMs: env.otlpExportIntervalMs,
otlpServiceName: env.otlpServiceName,
otlpHeaders: env.otlpHeaders,
diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts
index 94c88b294770..9ff9a8e13b39 100644
--- a/apps/server/src/cli/pair.ts
+++ b/apps/server/src/cli/pair.ts
@@ -320,6 +320,7 @@ const makePairServerConfig = Effect.fn(function* (input: {
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otlpHeaders: undefined,
diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
index 919628c8d1ed..4835ebb40e44 100644
--- a/apps/server/src/config.ts
+++ b/apps/server/src/config.ts
@@ -72,6 +72,7 @@ export class ServerConfig extends Context.Service<
readonly traceMaxFiles: number;
readonly otlpTracesUrl: string | undefined;
readonly otlpMetricsUrl: string | undefined;
+ readonly otlpLogsUrl: string | undefined;
readonly otlpExportIntervalMs: number;
readonly otlpServiceName: string;
readonly otlpHeaders: Readonly> | undefined;
@@ -106,6 +107,18 @@ export class ServerConfig extends Context.Service<
export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config);
+/**
+ * Resource attributes shared by every OTLP exporter, so traces, metrics, and
+ * logs report the same service identity to the collector.
+ */
+export const otlpResource = (config: ServerConfig["Service"]) => ({
+ serviceName: config.otlpServiceName,
+ attributes: {
+ "service.runtime": "t3-server",
+ "service.mode": config.mode,
+ },
+});
+
export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerConfig, make(config));
export const deriveServerPaths = Effect.fn(function* (
@@ -198,6 +211,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* (
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otlpHeaders: undefined,
diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts
index b8cb84349472..0ccf8de691eb 100644
--- a/apps/server/src/environment/ServerEnvironment.test.ts
+++ b/apps/server/src/environment/ServerEnvironment.test.ts
@@ -53,6 +53,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) {
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otlpHeaders: undefined,
diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts
index 7a7aaf910a9b..0c9acdfb1ee3 100644
--- a/apps/server/src/observability/Layers/Observability.ts
+++ b/apps/server/src/observability/Layers/Observability.ts
@@ -21,6 +21,7 @@ export const ObservabilityLive = Layer.unwrap(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const serializationLayer = otlpSerializationLayer(config.otlpProtocol);
+ const resource = ServerConfig.otlpResource(config);
const attribution = yield* ResourceAttribution.ResourceAttribution;
const traceReferencesLayer = Layer.mergeAll(
@@ -52,13 +53,7 @@ export const ObservabilityLive = Layer.unwrap(
url: config.otlpTracesUrl,
exportInterval: `${config.otlpExportIntervalMs} millis`,
headers: config.otlpHeaders,
- resource: {
- serviceName: config.otlpServiceName,
- attributes: {
- "service.runtime": "t3-server",
- "service.mode": config.mode,
- },
- },
+ resource,
});
const tracer = yield* makeLocalFileTracer({
@@ -84,13 +79,7 @@ export const ObservabilityLive = Layer.unwrap(
url: config.otlpMetricsUrl,
exportInterval: `${config.otlpExportIntervalMs} millis`,
headers: config.otlpHeaders,
- resource: {
- serviceName: config.otlpServiceName,
- attributes: {
- "service.runtime": "t3-server",
- "service.mode": config.mode,
- },
- },
+ resource,
}).pipe(Layer.provideMerge(serializationLayer));
return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index cb2cf03659ef..512e55a64cbb 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -580,6 +580,7 @@ const buildAppUnderTest = (options?: {
traceMaxFiles: 10,
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
otlpExportIntervalMs: 10_000,
otlpServiceName: "t3-server",
otlpHeaders: undefined,
@@ -6291,6 +6292,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
config: {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
},
layers: {
keybindings: {
@@ -6326,6 +6328,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
assert.equal(first.config.observability.otlpTracesEnabled, true);
assert.equal(first.config.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics");
assert.equal(first.config.observability.otlpMetricsEnabled, true);
+ assert.equal(first.config.observability.otlpLogsUrl, "http://localhost:4318/v1/logs");
+ assert.equal(first.config.observability.otlpLogsEnabled, true);
assert.deepEqual(first.config.settings, DEFAULT_SERVER_SETTINGS);
}
assert.deepEqual(second, {
diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts
new file mode 100644
index 000000000000..59ca908cb4ad
--- /dev/null
+++ b/apps/server/src/serverLogger.test.ts
@@ -0,0 +1,197 @@
+import * as NodePath from "@effect/platform-node/NodePath";
+import { assert, describe, it } from "@effect/vitest";
+import * as NodeOS from "node:os";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as Tracer from "effect/Tracer";
+import * as HttpClient from "effect/unstable/http/HttpClient";
+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
+
+import * as ServerConfig from "./config.ts";
+import { ServerLoggerLive } from "./serverLogger.ts";
+
+interface ExportedRequest {
+ readonly url: string;
+ readonly headers: Readonly>;
+ readonly body: string;
+}
+
+/** Answers every export with a 200 and keeps what was posted for assertions. */
+const collectorLayer = (requests: Array) =>
+ Layer.succeed(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.sync(() => {
+ requests.push({
+ url: request.url,
+ headers: request.headers,
+ body:
+ request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "",
+ });
+ return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }));
+ }),
+ ),
+ );
+
+const configLayer = (overrides: Partial) =>
+ Layer.effect(
+ ServerConfig.ServerConfig,
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const baseDir = path.join(NodeOS.tmpdir(), "t3-server-logger-test");
+ const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined);
+ return ServerConfig.make({
+ logLevel: "Info",
+ traceMinLevel: "Info",
+ traceTimingEnabled: false,
+ traceBatchWindowMs: 200,
+ traceMaxBytes: 1024,
+ traceMaxFiles: 1,
+ otlpTracesUrl: undefined,
+ otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
+ otlpExportIntervalMs: 10_000,
+ otlpServiceName: "t3-server",
+ otlpHeaders: undefined,
+ otlpProtocol: "http/json",
+ cwd: baseDir,
+ baseDir,
+ ...derivedPaths,
+ mode: "web",
+ autoBootstrapProjectFromCwd: false,
+ logWebSocketEvents: false,
+ tailscaleServeEnabled: false,
+ tailscaleServePort: 443,
+ port: 0,
+ host: undefined,
+ desktopBootstrapToken: undefined,
+ desktopTelemetryFd: undefined,
+ desktopTelemetryControlFd: undefined,
+ resourceMonitorPath: undefined,
+ staticDir: undefined,
+ devUrl: undefined,
+ devAllowedOrigins: [],
+ noBrowser: false,
+ startupPresentation: "browser",
+ ...overrides,
+ });
+ }),
+ ).pipe(Layer.provide(NodePath.layer));
+
+/**
+ * Logs once with the server's own logger set installed, then reports what the
+ * collector received. The export is asserted after the layer's scope closes,
+ * which is where the exporter flushes whatever the interval did not.
+ */
+const logThrough = (overrides: Partial) =>
+ Effect.gen(function* () {
+ const requests: Array = [];
+ yield* Effect.log("server logger under test").pipe(
+ Effect.provide(
+ ServerLoggerLive.pipe(
+ Layer.provide(configLayer(overrides)),
+ Layer.provide(collectorLayer(requests)),
+ ),
+ ),
+ );
+ return requests;
+ });
+
+/**
+ * Logs inside a span so the tracer logger has somewhere to attach an event,
+ * then reports both what the collector received and what landed on the span.
+ */
+const logInSpanThrough = (overrides: Partial) =>
+ Effect.gen(function* () {
+ const requests: Array = [];
+ const spans: Array = [];
+ const tracerLayer = Layer.succeed(
+ Tracer.Tracer,
+ Tracer.make({
+ span: (spanOptions) => {
+ const span = new Tracer.NativeSpan(spanOptions);
+ spans.push(span);
+ return span;
+ },
+ }),
+ );
+ yield* Effect.log("server logger under test").pipe(
+ Effect.withSpan("server-logger-test"),
+ Effect.provide(
+ Layer.mergeAll(
+ ServerLoggerLive.pipe(
+ Layer.provide(configLayer(overrides)),
+ Layer.provide(collectorLayer(requests)),
+ ),
+ tracerLayer,
+ ),
+ ),
+ );
+ return { requests, spans };
+ });
+
+describe("ServerLoggerLive", () => {
+ it.effect("exports log records to the configured logs endpoint", () =>
+ Effect.gen(function* () {
+ const requests = yield* logThrough({
+ otlpLogsUrl: "https://collector.example.com/v1/logs",
+ });
+
+ assert.lengthOf(requests, 1);
+ const [request] = requests;
+ assert.strictEqual(request?.url, "https://collector.example.com/v1/logs");
+ assert.include(request?.body ?? "", "server logger under test");
+ assert.include(request?.body ?? "", "t3-server");
+ assert.include(request?.body ?? "", "service.runtime");
+ }),
+ );
+
+ it.effect("stays off the network when no logs endpoint is configured", () =>
+ Effect.gen(function* () {
+ const requests = yield* logThrough({});
+
+ assert.lengthOf(requests, 0);
+ }),
+ );
+
+ it.effect("sends the headers and wire format the rest of OTLP export already uses", () =>
+ Effect.gen(function* () {
+ const requests = yield* logThrough({
+ otlpLogsUrl: "https://collector.example.com/v1/logs",
+ otlpProtocol: "http/protobuf",
+ otlpHeaders: { "x-scope": "logs" },
+ });
+
+ assert.lengthOf(requests, 1);
+ assert.strictEqual(requests[0]?.headers["x-scope"], "logs");
+ assert.strictEqual(requests[0]?.headers["content-type"], "application/x-protobuf");
+ }),
+ );
+
+ it.effect("attaches log messages to the active span when no logs endpoint is configured", () =>
+ Effect.gen(function* () {
+ const { requests, spans } = yield* logInSpanThrough({});
+
+ assert.lengthOf(requests, 0);
+ assert.lengthOf(spans, 1);
+ assert.deepEqual(
+ spans[0]?.events.map(([name]) => name),
+ ["server logger under test"],
+ );
+ }),
+ );
+
+ it.effect("stops duplicating messages onto the span once log records are exported", () =>
+ Effect.gen(function* () {
+ const { requests, spans } = yield* logInSpanThrough({
+ otlpLogsUrl: "https://collector.example.com/v1/logs",
+ });
+
+ assert.lengthOf(requests, 1);
+ assert.include(requests[0]?.body ?? "", "server logger under test");
+ assert.lengthOf(spans, 1);
+ assert.lengthOf(spans[0]?.events ?? [], 0);
+ }),
+ );
+});
diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts
index a7cb1d6a26ee..389a9535efab 100644
--- a/apps/server/src/serverLogger.ts
+++ b/apps/server/src/serverLogger.ts
@@ -1,16 +1,48 @@
+import { otlpSerializationLayer } from "@t3tools/shared/observability";
import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as References from "effect/References";
-import * as Layer from "effect/Layer";
+import * as OtlpExporter from "effect/unstable/observability/OtlpExporter";
+import * as OtlpLogger from "effect/unstable/observability/OtlpLogger";
-import { ServerConfig } from "./config.ts";
+import { otlpResource, ServerConfig } from "./config.ts";
export const ServerLoggerLive = Effect.gen(function* () {
const config = yield* ServerConfig;
const minimumLogLevelLayer = Layer.succeed(References.MinimumLogLevel, config.logLevel);
- const loggerLayer = Logger.layer([Logger.consolePretty(), Logger.tracerLogger], {
- mergeWithExisting: false,
- });
+
+ const otlpLogger =
+ config.otlpLogsUrl === undefined
+ ? undefined
+ : OtlpLogger.make({
+ url: config.otlpLogsUrl,
+ exportInterval: `${config.otlpExportIntervalMs} millis`,
+ headers: config.otlpHeaders,
+ resource: otlpResource(config),
+ });
+
+ // `Logger.layer` writes the whole logger set rather than adding to it, so
+ // every logger the server wants has to be named in this one call.
+ //
+ // `Logger.tracerLogger` reaches a collector by attaching each message to the
+ // active span as a span event, which covers only messages logged inside a
+ // recorded span and files them under traces. The OTLP logger carries the same
+ // messages as log records stamped with their trace and span ids, so it is a
+ // superset: keeping both would export every in-span message twice.
+ //
+ // Recording events on spans is also the shape OpenTelemetry is deprecating,
+ // in favor of the log-based events this logger emits:
+ // https://opentelemetry.io/blog/2026/deprecating-span-events/
+ const loggerLayer = Logger.layer(
+ otlpLogger === undefined
+ ? [Logger.consolePretty(), Logger.tracerLogger]
+ : [Logger.consolePretty(), otlpLogger],
+ { mergeWithExisting: false },
+ ).pipe(
+ Layer.provide(OtlpExporter.layerFlusher),
+ Layer.provide(otlpSerializationLayer(config.otlpProtocol)),
+ );
return Layer.mergeAll(loggerLayer, minimumLogLevelLayer);
}).pipe(Layer.unwrap);
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index bd216a0e88a1..b2caba4a0347 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -969,6 +969,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
observability: {
otlpTracesUrl: " http://localhost:4318/v1/traces ",
otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
+ otlpLogsUrl: " http://localhost:4318/v1/logs ",
},
});
@@ -976,6 +977,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
assert.deepEqual(next.observability, {
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
});
}).pipe(Effect.provide(makeServerSettingsLayer())),
);
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 0b9bd1136bf3..d118bd3d012c 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -1808,6 +1808,8 @@ const makeWsRpcLayer = (
? { otlpMetricsUrl: config.otlpMetricsUrl }
: {}),
otlpMetricsEnabled: config.otlpMetricsUrl !== undefined,
+ ...(config.otlpLogsUrl !== undefined ? { otlpLogsUrl: config.otlpLogsUrl } : {}),
+ otlpLogsEnabled: config.otlpLogsUrl !== undefined,
},
settings,
shellResumeCompletionMarker: true,
diff --git a/docs/operations/observability.md b/docs/operations/observability.md
index 5668ca9d6fbb..215b1706aeef 100644
--- a/docs/operations/observability.md
+++ b/docs/operations/observability.md
@@ -6,7 +6,7 @@ T3 Code has one server-side observability model:
- pretty logs go to stdout for humans
- completed spans go to a local NDJSON trace file
-- traces and metrics can also be exported over OTLP to a real backend like Grafana LGTM
+- traces, metrics, and logs can also be exported over OTLP to a real backend like Grafana LGTM
The local trace file is the persisted source of truth for normal local launches. Those launches do not
write a separate server log file, but SSH-managed launches also persist the remote process's
@@ -22,9 +22,16 @@ Logs are human-facing:
- format: `Logger.consolePretty()`
- normal local persistence: none
- SSH-managed launch persistence: `~/.t3/ssh-launch//server.log`
+- remote export: OTLP only, when configured
If you want a log message to show up in the trace file, emit it inside an active span with `Effect.log...`. `Logger.tracerLogger` will attach it as a span event.
+Configuring a logs endpoint takes over that job. The server then exports log records, which cover
+every message instead of only the ones inside an active span and carry the trace and span ids so
+they still line up with the trace. `Logger.tracerLogger` is dropped in that mode, so the same
+message is not exported twice and the trace file stops carrying log messages. stdout output and
+SSH-managed launch persistence stay unchanged either way.
+
### Traces
Completed spans are written as NDJSON records to `serverTracePath`. The default depends on how the
@@ -121,6 +128,7 @@ Default Grafana login:
```bash
export T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces
export T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics
+export T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs
export T3CODE_OTLP_SERVICE_NAME=t3-local
```
@@ -160,6 +168,7 @@ macOS app bundle example:
```bash
T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \
T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \
+T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \
T3CODE_OTLP_SERVICE_NAME=t3-desktop \
"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/Applications/T3 Code.app/Contents/MacOS/T3 Code"
```
@@ -169,6 +178,7 @@ Direct binary example:
```bash
T3CODE_OTLP_TRACES_URL=http://localhost:4318/v1/traces \
T3CODE_OTLP_METRICS_URL=http://localhost:4318/v1/metrics \
+T3CODE_OTLP_LOGS_URL=http://localhost:4318/v1/logs \
T3CODE_OTLP_SERVICE_NAME=t3-desktop \
./path/to/your/desktop-app-binary
```
@@ -509,6 +519,7 @@ It provides:
- local NDJSON tracer
- optional OTLP trace exporter
- optional OTLP metrics exporter
+- optional OTLP log exporter
- Effect trace-level and timing refs
### Env Vars
@@ -526,13 +537,15 @@ OTLP export:
- `T3CODE_OTLP_TRACES_URL`: OTLP trace endpoint
- `T3CODE_OTLP_METRICS_URL`: OTLP metric endpoint
+- `T3CODE_OTLP_LOGS_URL`: OTLP log endpoint
- `T3CODE_OTLP_EXPORT_INTERVAL_MS`: export interval, default `10000`
- `T3CODE_OTLP_SERVICE_NAME`: service name, default `t3-server`
-- `T3CODE_OTLP_HEADERS`: extra headers for both exporters, same format as
+- `T3CODE_OTLP_HEADERS`: extra headers for all three exporters, same format as
`OTEL_EXPORTER_OTLP_HEADERS`: comma-separated `key=value` pairs with percent-encoded values.
- `T3CODE_OTLP_PROTOCOL`: `http/json` (default) or `http/protobuf`
-If the OTLP URLs are unset, local tracing still works and metrics stay in-process only.
+If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
+on stdout only.
### What Is Instrumented Today
diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts
index 4687b84c359f..65b537921f09 100644
--- a/packages/client-runtime/src/rpc/session.test.ts
+++ b/packages/client-runtime/src/rpc/session.test.ts
@@ -149,6 +149,7 @@ const SERVER_CONFIG: ServerConfigType = {
localTracingEnabled: false,
otlpTracesEnabled: false,
otlpMetricsEnabled: false,
+ otlpLogsEnabled: false,
},
settings: DEFAULT_SERVER_SETTINGS,
};
diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts
index f4d1a0927861..bc3558aa2962 100644
--- a/packages/contracts/src/desktopBootstrap.ts
+++ b/packages/contracts/src/desktopBootstrap.ts
@@ -16,6 +16,7 @@ export const DesktopBackendBootstrap = Schema.Struct({
tailscaleServePort: PortSchema,
otlpTracesUrl: Schema.optional(Schema.String),
otlpMetricsUrl: Schema.optional(Schema.String),
+ otlpLogsUrl: Schema.optional(Schema.String),
desktopTelemetryFd: Schema.optionalKey(PositiveInt),
desktopTelemetryControlFd: Schema.optionalKey(PositiveInt),
resourceMonitorPath: Schema.optionalKey(TrimmedNonEmptyString),
diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts
index 0a7e342ab752..9112896eb1c9 100644
--- a/packages/contracts/src/server.test.ts
+++ b/packages/contracts/src/server.test.ts
@@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts";
import {
resolveEnvironmentMachineKind,
ServerConfig,
+ ServerObservability,
ServerProvider,
ServerProviders,
ServerUpsertKeybindingResult,
@@ -13,6 +14,7 @@ import { ServerSettings } from "./settings.ts";
const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders);
+const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability);
const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult);
const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors);
@@ -177,6 +179,21 @@ describe("server config forward compatibility", () => {
});
});
+describe("ServerObservability", () => {
+ it("reads a server from before the log signal as exporting no logs", () => {
+ const parsed = decodeServerObservability({
+ logsDirectoryPath: "/tmp/t3/logs",
+ localTracingEnabled: true,
+ otlpTracesUrl: "https://collector.example.com/v1/traces",
+ otlpTracesEnabled: true,
+ otlpMetricsEnabled: false,
+ });
+
+ expect(parsed.otlpLogsEnabled).toBe(false);
+ expect(parsed.otlpLogsUrl).toBeUndefined();
+ });
+});
+
describe("resolveEnvironmentMachineKind", () => {
const decodeDescriptor = Schema.decodeUnknownSync(ExecutionEnvironmentDescriptor);
const decodeSettings = Schema.decodeUnknownSync(ServerSettings);
diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts
index 8073b3f4ed0a..49a377208799 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -261,6 +261,10 @@ export const ServerObservability = Schema.Struct({
otlpTracesEnabled: Schema.Boolean,
otlpMetricsUrl: Schema.optional(TrimmedNonEmptyString),
otlpMetricsEnabled: Schema.Boolean,
+ otlpLogsUrl: Schema.optional(TrimmedNonEmptyString),
+ // Absent on servers from before the log signal shipped, so a newer client
+ // reads those as having no log export rather than rejecting the whole config.
+ otlpLogsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
});
export type ServerObservability = typeof ServerObservability.Type;
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 9de1f5ebb8dc..42e91a76ecaf 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -889,6 +889,7 @@ export type UsageLimitSourceConfig = typeof UsageLimitSourceConfig.Type;
export const ObservabilitySettings = Schema.Struct({
otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
+ otlpLogsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
});
export type ObservabilitySettings = typeof ObservabilitySettings.Type;
@@ -1481,6 +1482,7 @@ export const ServerSettingsPatch = Schema.Struct({
Schema.Struct({
otlpTracesUrl: Schema.optionalKey(TrimmedString),
otlpMetricsUrl: Schema.optionalKey(TrimmedString),
+ otlpLogsUrl: Schema.optionalKey(TrimmedString),
}),
),
providers: Schema.optionalKey(
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index 267e7a7b3442..05f625a1bd0f 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -224,14 +224,18 @@ describe("serverSettings helpers", () => {
expect(parsePersistedServerObservabilitySettings("{}")).toEqual({
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
});
expect(
parsePersistedServerObservabilitySettings(
- JSON.stringify({ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "" } }),
+ JSON.stringify({
+ observability: { otlpTracesUrl: " ", otlpMetricsUrl: "", otlpLogsUrl: " " },
+ }),
),
).toEqual({
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
});
});
@@ -242,12 +246,14 @@ describe("serverSettings helpers", () => {
observability: {
otlpTracesUrl: " http://localhost:4318/v1/traces ",
otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
+ otlpLogsUrl: " http://localhost:4318/v1/logs ",
},
}),
),
).toEqual({
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
+ otlpLogsUrl: "http://localhost:4318/v1/logs",
});
});
@@ -255,6 +261,7 @@ describe("serverSettings helpers", () => {
expect(parsePersistedServerObservabilitySettings("{")).toEqual({
otlpTracesUrl: undefined,
otlpMetricsUrl: undefined,
+ otlpLogsUrl: undefined,
});
});
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index 61103b77babd..d847cb2b0795 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -102,6 +102,7 @@ export function resolveSourceControlWriterModelSelection(
export interface PersistedServerObservabilitySettings {
readonly otlpTracesUrl: string | undefined;
readonly otlpMetricsUrl: string | undefined;
+ readonly otlpLogsUrl: string | undefined;
}
function normalizePersistedServerSettingString(
@@ -115,11 +116,13 @@ function extractPersistedServerObservabilitySettings(input: {
readonly observability?: {
readonly otlpTracesUrl?: string;
readonly otlpMetricsUrl?: string;
+ readonly otlpLogsUrl?: string;
};
}): PersistedServerObservabilitySettings {
return {
otlpTracesUrl: normalizePersistedServerSettingString(input.observability?.otlpTracesUrl),
otlpMetricsUrl: normalizePersistedServerSettingString(input.observability?.otlpMetricsUrl),
+ otlpLogsUrl: normalizePersistedServerSettingString(input.observability?.otlpLogsUrl),
};
}
@@ -130,7 +133,7 @@ export function parsePersistedServerObservabilitySettings(
if (Option.isSome(decoded)) {
return extractPersistedServerObservabilitySettings(decoded.value);
}
- return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
+ return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined, otlpLogsUrl: undefined };
}
function shouldReplaceTextGenerationModelSelection(
From ea15f17afd94e7ce7133e52f5b5983910662c9ec Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 16:09:00 -0700
Subject: [PATCH 032/111] fix(mobile): use native settings and snooze controls
(#12512)
---
.../sections/CodeAppearanceSection.tsx | 2 +-
.../sections/TerminalAppearanceSection.tsx | 2 +-
.../sections/TextAppearanceSection.tsx | 2 +-
.../settings/components/SettingsSection.tsx | 6 +-
.../threads/CustomSnoozeSheet.android.tsx | 12 +-
.../threads/CustomSnoozeSheet.ios.tsx | 296 ++++++++++--------
.../threads/CustomSnoozeSheet.shared.tsx | 4 +-
.../features/threads/ThreadSettingsSheet.tsx | 196 ++++++------
.../threads/provider-catalog-refresh.ts | 2 +-
.../src/features/usage/UsageRouteScreen.tsx | 6 +-
10 files changed, 284 insertions(+), 244 deletions(-)
diff --git a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx
index bb4bb4d0a8fe..6760504bfa28 100644
--- a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx
+++ b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx
@@ -26,7 +26,7 @@ export function CodeAppearanceSection() {
);
return (
-
+
+
+
@@ -30,9 +28,7 @@ export function SettingsSection(props: {
className={
Platform.OS === "android"
? "overflow-hidden rounded-[28px] bg-card"
- : props.card
- ? "overflow-hidden rounded-[24px] border-continuous bg-card"
- : "overflow-hidden rounded-[24px] border-continuous bg-card android:bg-transparent"
+ : "overflow-hidden rounded-[24px] border-continuous bg-card"
}
>
{props.children}
diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx
index 85600838881f..e066c01b43cf 100644
--- a/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx
+++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx
@@ -1,7 +1,6 @@
import { MaterialSegmentedButtons } from "../../components/MaterialSegmentedButtons.android";
import {
BasicAlertDialog,
- Button,
Column,
DateTimePicker,
Host,
@@ -240,19 +239,16 @@ export function CustomSnoozeSheet(props: Props) {
Cancel
-
+
diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx
index 20176064be52..c8d9abbc5fa8 100644
--- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx
+++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx
@@ -1,27 +1,22 @@
import {
- Button,
DatePicker,
Host,
HStack,
Picker,
Popover,
+ RNHostView,
Spacer,
Text,
VStack,
} from "@expo/ui/swift-ui";
import {
- accessibilityAddTraits,
accessibilityHidden,
- buttonBorderShape,
- buttonStyle,
clipped,
- controlSize,
font,
datePickerStyle,
foregroundStyle,
frame,
labelsHidden,
- labelStyle,
padding,
pickerStyle,
tag,
@@ -32,12 +27,17 @@ import {
resolveCustomSnooze,
type CustomSnoozeInput,
} from "@t3tools/client-runtime/state/thread-settled";
-import { useState } from "react";
-import { useWindowDimensions } from "react-native";
+import { useState, type ReactNode } from "react";
+import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native";
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import { ScrollView, useWindowDimensions, View } from "react-native";
+import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
+import { NativeHeaderToolbar } from "../../native/StackHeader";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1);
+const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>();
const modes = [
{ value: "date", label: "Date and time" },
{ value: "duration", label: "Duration" },
@@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: {
readonly onClose: () => void;
readonly onSnooze: (snoozedUntil: string) => void;
}) {
- const { width } = useWindowDimensions();
+ const { width, height } = useWindowDimensions();
const [mode, setMode] = useState("date");
const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000));
const [amount, setAmount] = useState(2);
const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours");
const [error, setError] = useState(null);
const { themeVariables: colors, themeAppearance } = useAppearancePreferences();
+ const popoverWidth = Math.min(360, width - 32);
+ const popoverHeight = Math.min(error ? 364 : 324, height - 96);
const updateDate = (value: Date) => {
setDate(value);
setError(null);
@@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: {
/>
-
-
-
-
- Custom snooze
-
-
-
- {
- setMode(value);
- setError(null);
- }}
- modifiers={[pickerStyle("segmented")]}
+
- {modes.map((option) => (
-
- {option.label}
-
- ))}
-
- {mode === "date" ? (
-
- ) : (
-
- {
- setAmount(value);
- setError(null);
- }}
- modifiers={[
- pickerStyle("wheel"),
- labelsHidden(),
- frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
- clipped(),
- ]}
- >
- {durationAmounts.map((value) => (
-
- {String(value)}
-
- ))}
-
+
{
- setUnit(value);
+ label="Snooze mode"
+ selection={mode}
+ onSelectionChange={(value: CustomSnoozeInput["mode"]) => {
+ setMode(value);
setError(null);
}}
- modifiers={[
- pickerStyle("wheel"),
- labelsHidden(),
- frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
- clipped(),
- ]}
+ modifiers={[pickerStyle("segmented")]}
>
- {units.map((option) => (
-
+ {modes.map((option) => (
+
{option.label}
))}
-
- )}
- {error ? (
-
- {error}
-
- ) : null}
-
+ {mode === "date" ? (
+
+ ) : (
+
+ {
+ setAmount(value);
+ setError(null);
+ }}
+ modifiers={[
+ pickerStyle("wheel"),
+ labelsHidden(),
+ frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
+ clipped(),
+ ]}
+ >
+ {durationAmounts.map((value) => (
+
+ {String(value)}
+
+ ))}
+
+ {
+ setUnit(value);
+ setError(null);
+ }}
+ modifiers={[
+ pickerStyle("wheel"),
+ labelsHidden(),
+ frame({ minWidth: 0, maxWidth: Infinity, height: 180 }),
+ clipped(),
+ ]}
+ >
+ {units.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
);
}
+
+/** The popover owns a native navigation bar, just like the app's sheet screens. */
+function SnoozePopoverNavigation(props: {
+ readonly width: number;
+ readonly height: number;
+ readonly onClose: () => void;
+ readonly onSubmit: () => void;
+ readonly children: ReactNode;
+}) {
+ const navigationTheme = useMobileNavigationTheme();
+ const { themeVariables: colors } = useAppearancePreferences();
+
+ return (
+
+
+
+
+
+
+
+ {() => (
+ <>
+
+
+
+
+
+
+
+ {props.children}
+
+ >
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx
index 9278bb6b941e..5fc60bc05dfd 100644
--- a/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx
+++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.shared.tsx
@@ -152,7 +152,7 @@ export function CustomSnoozeSheet(props: {
{
const input: CustomSnoozeInput =
mode === "date"
@@ -171,7 +171,7 @@ export function CustomSnoozeSheet(props: {
props.onClose();
}}
>
- Snooze
+ Snooze
diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
index b8411229a8ac..7be5dc4bbdbc 100644
--- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
+++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
@@ -9,7 +9,6 @@ import type {
import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
-import { HeaderHeightContext } from "@react-navigation/elements";
import {
getProviderOptionCurrentLabel,
getProviderOptionCurrentValue,
@@ -31,13 +30,22 @@ import {
useState,
type ReactNode,
} from "react";
-import { Alert, Platform, Pressable, ScrollView, TextInput, View } from "react-native";
+import {
+ Alert,
+ Platform,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ TextInput,
+ View,
+} from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
+import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
import { MaterialButton } from "../../components/MaterialButton";
import { MaterialIconButton } from "../../components/MaterialIconButton";
import { ProviderIcon } from "../../components/ProviderIcon";
@@ -759,12 +767,27 @@ function ThreadSettingsMainContent(props: {
readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
}) {
const session = useThreadSettingsSession();
+ const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
+ reportFailure: false,
+ });
+ const refreshProviderCatalog = useMemo(
+ () => createProviderCatalogRefreshRunner(refreshProvidersCommand),
+ [refreshProvidersCommand],
+ );
+ const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
+ const refreshProviders = useCallback(() => {
+ if (!session.environmentId || isRefreshingProviders) return;
+ setIsRefreshingProviders(true);
+ void refreshProviderCatalog(session.environmentId).then((result) => {
+ setIsRefreshingProviders(false);
+ const error = providerCatalogRefreshError(result);
+ if (error) Alert.alert("Could not refresh models", error);
+ });
+ }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
const catalogItems = useThreadSettingsCatalogItems(session);
const [animationsReady, setAnimationsReady] = useState(false);
- const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
const hasActiveCatalogFilter =
session.providerFilter !== null || session.searchQuery.trim().length > 0;
- const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;
const listItems = useMemo>(
() => [
...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
@@ -830,6 +853,7 @@ function ThreadSettingsMainContent(props: {
return (
- {usesTransparentNativeHeader ? : null}
{Platform.OS === "android" ? (
) : null}
-
- session.setProviderFilter(null)}
- >
- All
-
- session.setProviderFilter(FAVORITES_PROVIDER_FILTER)}
- >
- Favorites
-
-
) : null}
>
}
recycleItems
+ refreshControl={
+ session.environmentId ? (
+
+ ) : undefined
+ }
onLoad={() => setAnimationsReady(true)}
renderItem={renderCatalogItem}
showsVerticalScrollIndicator={false}
@@ -1042,27 +1038,22 @@ function ThreadSettingsModelsScreen() {
const navigation = useNavigation>();
const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
- const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
- reportFailure: false,
- });
- const refreshProviderCatalog = useMemo(
- () => createProviderCatalogRefreshRunner(refreshProvidersCommand),
- [refreshProvidersCommand],
- );
- const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
- const refreshProviders = useCallback(() => {
- if (!session.environmentId || isRefreshingProviders) return;
- setIsRefreshingProviders(true);
- void refreshProviderCatalog(session.environmentId).then((result) => {
- setIsRefreshingProviders(false);
- const error = providerCatalogRefreshError(result);
- if (error) Alert.alert("Could not refresh models", error);
- });
- }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
const commitAndClose = useCallback(() => {
if (!session.commitPendingModel()) return;
presentation.onClose();
}, [presentation, session]);
+ const providerFilters = useMemo(
+ () => [
+ { id: "all-providers", title: "All providers", value: null },
+ { id: "favorites", title: "Favorites", value: FAVORITES_PROVIDER_FILTER },
+ ...session.providerGroups.map((group) => ({
+ id: `provider:${group.providerKey}`,
+ title: group.providerLabel,
+ value: group.providerKey,
+ })),
+ ],
+ [session.providerGroups],
+ );
const filterMenu = useMemo(
() => ({
title: "Model filters",
@@ -1070,30 +1061,12 @@ function ThreadSettingsModelsScreen() {
{
type: "submenu" as const,
title: "Provider",
- items: [
- {
- type: "action" as const,
- title: "All providers",
- state: session.providerFilter === null ? ("on" as const) : ("off" as const),
- onPress: () => session.setProviderFilter(null),
- },
- {
- type: "action" as const,
- title: "Favorites",
- state:
- session.providerFilter === FAVORITES_PROVIDER_FILTER
- ? ("on" as const)
- : ("off" as const),
- onPress: () => session.setProviderFilter(FAVORITES_PROVIDER_FILTER),
- },
- ...session.providerGroups.map((group) => ({
- type: "action" as const,
- title: group.providerLabel,
- state:
- session.providerFilter === group.providerKey ? ("on" as const) : ("off" as const),
- onPress: () => session.setProviderFilter(group.providerKey),
- })),
- ],
+ items: providerFilters.map((filter) => ({
+ type: "action" as const,
+ title: filter.title,
+ state: session.providerFilter === filter.value ? ("on" as const) : ("off" as const),
+ onPress: () => session.setProviderFilter(filter.value),
+ })),
},
...(session.hasLegacyModels
? [
@@ -1107,25 +1080,59 @@ function ThreadSettingsModelsScreen() {
: []),
],
}),
- [session],
+ [providerFilters, session],
);
return (
<>
{Platform.OS === "android" ? (
- ) : undefined
+
+ ({
+ id: filter.id,
+ title: filter.title,
+ state: session.providerFilter === filter.value ? "on" : "off",
+ })),
+ },
+ ...(session.hasLegacyModels
+ ? [
+ {
+ id: "show-legacy",
+ title: "Show legacy models",
+ state: session.showLegacy ? ("on" as const) : ("off" as const),
+ },
+ ]
+ : []),
+ ]}
+ onPressAction={({ nativeEvent }) => {
+ if (nativeEvent.event === "show-legacy") {
+ session.setShowLegacy(!session.showLegacy);
+ } else {
+ const filter = providerFilters.find((item) => item.id === nativeEvent.event);
+ if (filter) session.setProviderFilter(filter.value);
+ }
+ }}
+ >
+ {(open) => (
+
+ )}
+
+ {session.pendingModel ? (
+
+ ) : null}
+
}
onBack={presentation.onClose}
title="Thread settings"
@@ -1189,13 +1196,6 @@ function ThreadSettingsModelsScreen() {
/>
-
(
refreshProviders: (target: RefreshProvidersTarget) => Promise,
) {
diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx
index af45e9eefb2a..d1bec8cbc140 100644
--- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx
+++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx
@@ -412,7 +412,7 @@ function ProviderSection(props: {
);
return (
-
+
{ordered.map((provider, index) => {
const share = metric === "cost" ? provider.costShare : provider.tokenShare;
return (
@@ -463,7 +463,7 @@ function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24H
const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput;
return (
-
+
+
{merged.models.map((model, index) => (
Date: Fri, 18 Sep 2026 16:11:40 -0700
Subject: [PATCH 033/111] feat(web): sort pull requests by what is blocked on
me (#12508)
---
.../pullRequest/pullRequestList.logic.test.ts | 104 ++++++++++++++++++
.../pullRequest/pullRequestList.logic.ts | 52 +++++++++
.../pullRequest/pullRequestListPreferences.ts | 1 +
apps/web/src/routes/_chat.pull-requests.tsx | 5 +-
4 files changed, 161 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
index a90022ad72c9..9154e38026d2 100644
--- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts
@@ -21,6 +21,8 @@ import {
readPullRequestListSnapshot,
writePullRequestListSnapshot,
rankPullRequestMatches,
+ rankPullRequestsBlockedOnAuthor,
+ rankPullRequestsBlockedOnReviewer,
rankPullRequestsByMergeReadiness,
scorePullRequestMatch,
sortPullRequestGroups,
@@ -873,6 +875,108 @@ describe("default merge-readiness ranking", () => {
});
});
+describe("blocked-on-me ranking", () => {
+ it("ranks authored work by how surely it is the author's to unblock, newest first within a tier", () => {
+ const conflict = entry({
+ number: 1,
+ mergeability: "conflicting",
+ checksState: "failing",
+ updatedAt: "2026-08-01T00:00:00Z",
+ });
+ const olderFailing = entry({
+ number: 2,
+ checksState: "failing",
+ updatedAt: "2026-08-01T00:00:00Z",
+ });
+ const changesRequested = entry({
+ number: 3,
+ reviewDecision: "changes-requested",
+ checksState: "failing",
+ updatedAt: "2026-08-02T00:00:00Z",
+ });
+ const approved = entry({ number: 4, checksState: "passing", reviewDecision: "approved" });
+ const draft = entry({ number: 5, isDraft: true, checksState: "passing" });
+ const waiting = entry({ number: 6 });
+ const merged = entry({ number: 7, state: "merged", updatedAt: "2026-08-01T00:00:00Z" });
+ const newerClosed = entry({ number: 8, state: "closed", updatedAt: "2026-08-03T00:00:00Z" });
+ const newerFailing = entry({
+ number: 9,
+ checksState: "failing",
+ updatedAt: "2026-08-03T00:00:00Z",
+ });
+
+ expect(
+ rankPullRequestsBlockedOnAuthor([
+ waiting,
+ merged,
+ olderFailing,
+ draft,
+ approved,
+ changesRequested,
+ newerClosed,
+ conflict,
+ newerFailing,
+ ]).map((row) => row.number),
+ ).toEqual([1, 3, 9, 2, 5, 6, 4, 8, 7]);
+ });
+
+ it("ranks reviewer work with open rows first, newest first", () => {
+ const olderOpen = entry({ number: 1, updatedAt: "2026-08-01T00:00:00Z" });
+ const newerOpen = entry({ number: 2, updatedAt: "2026-08-02T00:00:00Z" });
+ const olderFinished = entry({ number: 3, state: "merged", updatedAt: "2026-08-01T00:00:00Z" });
+ const newerFinished = entry({ number: 4, state: "closed", updatedAt: "2026-08-03T00:00:00Z" });
+
+ expect(
+ rankPullRequestsBlockedOnReviewer([newerFinished, newerOpen, olderFinished, olderOpen]).map(
+ (row) => row.number,
+ ),
+ ).toEqual([2, 1, 4, 3]);
+ });
+
+ it("ranks blocked groups according to their involvement", () => {
+ const authored = [
+ entry({ number: 1, isDraft: true }),
+ entry({ number: 2, checksState: "failing" }),
+ ];
+ const reviewing = [
+ entry({ number: 3, updatedAt: "2026-08-02T00:00:00Z" }),
+ entry({ number: 4, updatedAt: "2026-08-01T00:00:00Z" }),
+ ];
+ const others = [
+ entry({ number: 5, updatedAt: "2026-08-02T00:00:00Z" }),
+ entry({ number: 6, isDraft: true }),
+ ];
+ const groups = [
+ { key: "authored", label: "Authored", entries: authored },
+ { key: "reviewRequested", label: "Review requested", entries: reviewing },
+ { key: "others", label: "Others", entries: others },
+ ] as const;
+
+ expect(
+ sortPullRequestGroups(groups, "blocked", "", undefined, "all").map((group) =>
+ group.entries.map((row) => row.number),
+ ),
+ ).toEqual([
+ [2, 1],
+ [3, 4],
+ [5, 6],
+ ]);
+ expect(
+ sortPullRequestGroups([groups[2]], "blocked", "", undefined, "authored")[0]!.entries.map(
+ (row) => row.number,
+ ),
+ ).toEqual([6, 5]);
+ expect(
+ sortPullRequestGroups([groups[2]], "blocked", "", undefined, "reviewing")[0]!.entries.map(
+ (row) => row.number,
+ ),
+ ).toEqual([5, 6]);
+ expect(sortPullRequestGroups(groups, "blocked", "needle", undefined, "authored")).toEqual(
+ groups,
+ );
+ });
+});
+
describe("line counts that arrive after the rows", () => {
const stats = new Map([["env-1 project-1 7", { additions: 42, deletions: 3 }]]);
diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts
index c2fdde3d011f..f001e33829c5 100644
--- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts
+++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts
@@ -1034,12 +1034,50 @@ export function rankPullRequestsByMergeReadiness(
+ entries: ReadonlyArray,
+ tier: (entry: Entry) => number,
+): ReadonlyArray {
+ const timestamp = (entry: Entry) => toSortableTimestamp(entry.updatedAt);
+ return entries.toSorted((left, right) => {
+ const byTier = tier(left) - tier(right);
+ if (byTier !== 0) return byTier;
+ const leftUpdated = timestamp(left);
+ const rightUpdated = timestamp(right);
+ const measured = Number(leftUpdated === null) - Number(rightUpdated === null);
+ if (measured !== 0) return measured;
+ if (leftUpdated === null || rightUpdated === null) return 0;
+ return rightUpdated - leftUpdated;
+ });
+}
+
+export function rankPullRequestsBlockedOnAuthor(
+ entries: ReadonlyArray,
+): ReadonlyArray {
+ return rankByTierThenRecency(entries, (entry) => {
+ if (entry.state !== "open") return 6;
+ if (entry.mergeability === "conflicting") return 0;
+ if (entry.reviewDecision === "changes-requested") return 1;
+ if (entry.checksState === "failing") return 2;
+ if (entry.isDraft) return 3;
+ if (entry.checksState === "passing" && entry.reviewDecision === "approved") return 5;
+ return 4;
+ });
+}
+
+export function rankPullRequestsBlockedOnReviewer(
+ entries: ReadonlyArray,
+): ReadonlyArray {
+ return rankByTierThenRecency(entries, (entry) => (entry.state === "open" ? 0 : 1));
+}
+
/** Keeps authored work first while applying the selected ordering inside every involvement group. */
export function sortPullRequestGroups(
groups: ReadonlyArray>,
sort: PullRequestListSort,
searchText: string,
hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0,
+ involvement: PullRequestInvolvement = "all",
): ReadonlyArray> {
const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) =>
groups.map((group) => ({ ...group, entries: rank(group.entries) }));
@@ -1049,6 +1087,20 @@ export function sortPullRequestGroups(
? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize))
: groups;
}
+ if (sort === "blocked") {
+ if (searchText.trim().length > 0) return groups;
+ const role = (key: PullRequestGroupKey) =>
+ key === "others" ? involvement : key === "authored" ? "authored" : "reviewing";
+ return groups.map((group) => {
+ const groupRole = role(group.key);
+ if (groupRole === "all") return group;
+ const rank =
+ groupRole === "authored"
+ ? rankPullRequestsBlockedOnAuthor
+ : rankPullRequestsBlockedOnReviewer;
+ return { ...group, entries: rank(group.entries) };
+ });
+ }
if (sort === "updated") return groups;
const timestamp = (entry: Entry) =>
diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts
index 95c4bf02e743..afb926c5862d 100644
--- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts
+++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts
@@ -10,6 +10,7 @@ import {
export const PullRequestListSort = Schema.Literals([
"ready",
+ "blocked",
"updated",
"newest",
"oldest",
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx
index bed2b65da5e4..f54415b1986c 100644
--- a/apps/web/src/routes/_chat.pull-requests.tsx
+++ b/apps/web/src/routes/_chat.pull-requests.tsx
@@ -28,6 +28,7 @@ import {
Maximize2Icon,
Minimize2Icon,
SearchIcon,
+ UserLockIcon,
} from "lucide-react";
import {
useCallback,
@@ -203,6 +204,7 @@ const STATE_TABS = [
const SORT_OPTIONS = [
{ value: "ready", label: "Merge readiness", Icon: ListChecksIcon },
+ { value: "blocked", label: "Blocked on me", Icon: UserLockIcon },
{ value: "updated", label: "Recently updated", Icon: ClockIcon },
{ value: "newest", label: "Newest shown", Icon: CalendarArrowDownIcon },
{ value: "oldest", label: "Oldest shown", Icon: CalendarArrowUpIcon },
@@ -1437,8 +1439,9 @@ function PullRequestsRouteView() {
typedParsed.text,
(entry) =>
entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)),
+ search.involvement,
);
- }, [groups, sort, statsByRow, typedParsed.text]);
+ }, [groups, search.involvement, sort, statsByRow, typedParsed.text]);
const listedPullRequestsBySurface = useMemo(
() =>
new Map(
From 8c3b5bef17a637bf53f297189350359790ef7afb Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 16:50:19 -0700
Subject: [PATCH 034/111] fix(mobile): prefer pull-to-refresh on list screens
(#12515)
---
.../src/components/ScreenHeader.android.tsx | 2 +-
.../archive/ArchivedThreadsScreen.tsx | 13 +++-
.../connection/CloudEnvironmentRows.tsx | 29 +--------
.../src/features/files/FileTreeBrowser.tsx | 32 ++++++----
.../features/files/MaterialFilesHeader.tsx | 23 +++++--
.../features/files/ThreadFilesRouteScreen.tsx | 13 +++-
.../files/thread-file-navigator-pane.tsx | 12 +---
.../SettingsEnvironmentsRouteScreen.tsx | 61 ++++++++++++++++++-
.../features/threads/git/GitOverviewSheet.tsx | 58 ++++++++++++------
9 files changed, 166 insertions(+), 77 deletions(-)
diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx
index edb0b770e93c..f7b70e695ad9 100644
--- a/apps/mobile/src/components/ScreenHeader.android.tsx
+++ b/apps/mobile/src/components/ScreenHeader.android.tsx
@@ -138,7 +138,7 @@ export function ScreenHeader(props: ScreenHeaderProps) {
icon: "magnifyingglass",
onPress: () => setSearchOpen(true),
},
- ...(search.onRefresh
+ ...(search.refreshInToolbar && search.onRefresh
? [
{
accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh",
diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
index a191a9c7175b..ea150180393c 100644
--- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
+++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
@@ -14,6 +14,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useMemo, useRef, type ComponentProps } from "react";
import {
ActivityIndicator,
+ Platform,
Pressable,
RefreshControl,
useWindowDimensions,
@@ -63,9 +64,6 @@ function ArchivedThreadsHeader(props: {
compactPlaceholder: "Search",
mode: "inline",
compactToolbar: width < 700,
- onRefresh: props.onRefresh,
- refreshInToolbar: true,
- refreshAccessibilityLabel: "Refresh archived threads",
}}
menus={[
{
@@ -110,6 +108,15 @@ function ArchivedThreadsHeader(props: {
},
],
},
+ ...(Platform.OS === "android"
+ ? [
+ {
+ id: "refresh",
+ title: "Refresh archived threads",
+ onPress: props.onRefresh,
+ },
+ ]
+ : []),
],
},
]}
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index cccc6425ec13..2828392ba8f3 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -41,16 +41,14 @@ interface CloudEnvironmentRowsProps {
readonly showcaseAvailableEnvironments?: ReadonlyArray;
readonly showcaseSignedIn?: boolean;
/**
- * Hide the "T3 Connect" section title + refresh button for hosts that
- * provide their own chrome (the onboarding sheet's native header and
- * pull-to-refresh).
+ * Hide the "T3 Connect" section title when the host provides its own header.
*/
readonly showHeader?: boolean;
}
/**
* "T3 Connect" section: every environment published to the signed-in account,
- * with connect switches, availability status, refresh, and loading/error
+ * with connect switches, availability status, and loading/error
* states. Shared between the Settings environments screen and the T3 Connect
* onboarding sheet.
*
@@ -120,29 +118,8 @@ function CloudEnvironmentRowsContent(
return (
{showHeader ? (
-
+
T3 Connect
- {discoveryAvailable ? (
- {
- void controller.refreshRelayEnvironments();
- }}
- className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
- >
- {controller.relayDiscovery.isRefreshing ? (
-
- ) : (
-
- )}
-
- ) : null}
) : null}
diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx
index f2dfd3f15a97..df18347f336c 100644
--- a/apps/mobile/src/features/files/FileTreeBrowser.tsx
+++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx
@@ -234,15 +234,6 @@ export function FileTreeBrowser(props: {
],
);
- if (props.error && props.entries.length === 0) {
- return (
-
- Files unavailable
- {props.error}
-
- );
- }
-
// SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and
// mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"`
// with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge
@@ -250,6 +241,7 @@ export function FileTreeBrowser(props: {
// flex-1 Views is ignored, which is why the tree rendered under the header with no blur.
return (
item.node.path}
@@ -270,7 +262,7 @@ export function FileTreeBrowser(props: {
renderItem={renderItem}
ListHeaderComponent={
<>
- {props.error ? (
+ {props.error && props.entries.length > 0 ? (
{props.error}
@@ -284,7 +276,25 @@ export function FileTreeBrowser(props: {
}
ListEmptyComponent={
- {props.isPending ? (
+ {props.error && props.entries.length === 0 ? (
+ <>
+ Files unavailable
+
+ {props.error}
+
+
+ Try again
+
+ >
+ ) : props.isPending ? (
) : (
<>
diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx
index 8936ae9b37ed..61d007ea5c3e 100644
--- a/apps/mobile/src/features/files/MaterialFilesHeader.tsx
+++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx
@@ -3,6 +3,7 @@ import { BackHandler, Keyboard, type TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
+import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
import { MaterialSearchField } from "../../components/MaterialSearchField";
/** Keep Files search in the same header row on compact and expanded layouts. */
@@ -55,12 +56,24 @@ export function MaterialFilesHeader(props: {
icon: "magnifyingglass",
onPress: () => setSearchOpen(true),
},
- {
- accessibilityLabel: "Refresh files",
- icon: "arrow.clockwise",
- onPress: props.onRefresh,
- },
]}
+ trailing={
+ {
+ if (nativeEvent.event === "refresh") props.onRefresh();
+ }}
+ >
+ {(open) => (
+
+ )}
+
+ }
/>
{searching ? (
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index 9e89ea1b24e4..06ef8f6ba4d9 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -77,11 +77,20 @@ function FilesBrowserHeader(props: {
value: props.searchQuery,
onChangeText: props.onSearchQueryChange,
placeholder: "Search files",
- onRefresh: props.onRefresh,
- refreshAccessibilityLabel: "Refresh files",
closeAccessibilityLabel: "Close file search",
clearAccessibilityLabel: "Clear file search",
}}
+ menus={
+ Platform.OS === "android"
+ ? [
+ {
+ title: "File options",
+ icon: "ellipsis",
+ items: [{ id: "refresh", title: "Refresh files", onPress: props.onRefresh }],
+ },
+ ]
+ : undefined
+ }
/>
);
}
diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
index f62ba6b7abba..c3c906e3d515 100644
--- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
+++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
@@ -55,16 +55,6 @@ export function ThreadFileNavigatorPane(props: {
const nativeHeaderRightBarButtonItems = useMemo(
() =>
[
- {
- accessibilityLabel: "Refresh files",
- icon: { name: "arrow.clockwise", type: "sfSymbol" as const },
- identifier: "thread-file-navigator-refresh",
- onPress: entriesQuery.refresh,
- sharesBackground: false,
- tintColor: foregroundColor,
- type: "button" as const,
- width: 44,
- },
{
accessibilityLabel: "Close files",
icon: { name: "xmark", type: "sfSymbol" as const },
@@ -76,7 +66,7 @@ export function ThreadFileNavigatorPane(props: {
width: 44,
},
] as ComponentProps["headerRightBarButtonItems"],
- [entriesQuery.refresh, foregroundColor, toggleAuxiliaryPane],
+ [foregroundColor, toggleAuxiliaryPane],
);
const fileTree = (
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
index ce6077db6163..7ca791afec6d 100644
--- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
@@ -1,16 +1,23 @@
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { useNavigation } from "@react-navigation/native";
+import { useAtomValue } from "@effect/atom-react";
+import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
import type { EnvironmentId } from "@t3tools/contracts";
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
+import { Platform, RefreshControl } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SettingsScreen } from "./components/SettingsScreen";
+import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu";
+import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader";
import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
import { LocalEnvironmentList } from "../connection/LocalEnvironmentList";
import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings";
import { splitEnvironmentSections } from "../connection/environmentSections";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
+import { relayEnvironmentDiscovery } from "../../state/relay";
+import { useAtomCommand } from "../../state/use-atom-command";
import {
applyShowcaseLocalEnvironmentDisplayUrls,
resolveShowcaseEnvironmentUpdateDisplayUrl,
@@ -42,6 +49,24 @@ export function SettingsEnvironmentsRouteScreen() {
: environmentSections.connectedCloudEnvironments;
const [expandedId, setExpandedId] = useState(null);
const headerIconColor = useUniwindTheme()["--color-icon"];
+ const relaySession = useAtomValue(managedRelaySessionAtom);
+ const refreshRelayEnvironments = useAtomCommand(
+ relayEnvironmentDiscovery.refresh,
+ "relay environment refresh",
+ );
+ const [isRefreshingCloud, setIsRefreshingCloud] = useState(false);
+ const cloudRefreshPendingRef = useRef(false);
+ async function refreshCloudEnvironments() {
+ if (!relaySession || cloudRefreshPendingRef.current) return;
+ cloudRefreshPendingRef.current = true;
+ setIsRefreshingCloud(true);
+ try {
+ await refreshRelayEnvironments();
+ } finally {
+ cloudRefreshPendingRef.current = false;
+ setIsRefreshingCloud(false);
+ }
+ }
const handleToggle = useCallback((environmentId: EnvironmentId) => {
setExpandedId((prev) => (prev === environmentId ? null : environmentId));
@@ -76,6 +101,31 @@ export function SettingsEnvironmentsRouteScreen() {
return (
{
+ if (nativeEvent.event === "refresh") void refreshCloudEnvironments();
+ }}
+ >
+ {(open) => (
+
+ )}
+
+ ) : undefined
+ }
actions={[
{
accessibilityLabel: "Add environment",
@@ -90,6 +140,7 @@ export function SettingsEnvironmentsRouteScreen() {
]}
>
void refreshCloudEnvironments()}
+ />
+ ) : undefined
+ }
>
@@ -431,6 +433,30 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
);
}
+ const refreshMenu = (
+ {
+ if (nativeEvent.event === "refresh") void handlePullRefresh();
+ }}
+ >
+ {(open) => (
+
+ )}
+
+ );
+
return (
{Platform.OS === "android" ? (
-
- void gitActions.refreshSelectedThreadGitStatus()}
- />
-
+ {refreshMenu}
) : (
navigation.goBack()}
- actions={[
- {
- accessibilityLabel: "Refresh repository status",
- disabled: busy,
- icon: "arrow.clockwise",
- onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
- },
- ]}
+ trailing={Platform.OS === "android" ? refreshMenu : undefined}
+ actions={
+ Platform.OS === "android"
+ ? undefined
+ : [
+ {
+ accessibilityLabel: "Refresh repository status",
+ disabled: busy,
+ icon: "arrow.clockwise",
+ onPress: () => void gitActions.refreshSelectedThreadGitStatus(),
+ },
+ ]
+ }
/>
)}
From de6a230db0bb71d3fa91d8de0ae7067b68253f82 Mon Sep 17 00:00:00 2001
From: shivam <91240327+shivamhwp@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:42:41 +0530
Subject: [PATCH 035/111] fix(acp): accept SDK elicitation requests (#11294)
---
packages/effect-acp/src/client.test.ts | 147 +++++++++++++++++++++++++
packages/effect-acp/src/client.ts | 22 +++-
packages/effect-acp/src/protocol.ts | 12 +-
packages/effect-acp/src/rpc.ts | 16 +++
4 files changed, 191 insertions(+), 6 deletions(-)
diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts
index 3a0b262e6e22..5d1bd1d3ac94 100644
--- a/packages/effect-acp/src/client.test.ts
+++ b/packages/effect-acp/src/client.test.ts
@@ -778,3 +778,150 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
}),
);
});
+
+const elicitationForm = {
+ sessionId: "session-1",
+ mode: "form" as const,
+ message: "Continue?",
+ requestedSchema: {
+ type: "object" as const,
+ properties: { choice: { type: "string" as const } },
+ required: ["choice"],
+ },
+};
+const decodeWireResponse = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
+const decodeWireError = Schema.decodeEffect(
+ Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Finite }) })),
+);
+
+it.effect.each(["accept", "decline", "cancel"] as const)(
+ "returns a flat %s action to the SDK and preserves the legacy response",
+ (action) =>
+ Effect.gen(function* () {
+ for (const method of ["elicitation/create", "session/elicitation"]) {
+ const { stdio, input, output } = yield* makeInMemoryStdio();
+ const acp = yield* AcpClient.make(stdio);
+ const answer = action === "accept" ? { action, content: { choice: "yes" } } : { action };
+ const response = { action: answer, _meta: { trace: "fixture" } };
+ yield* acp.handleElicitation((request) => {
+ assert.deepEqual(request, elicitationForm);
+ return Effect.succeed(response);
+ });
+ yield* Queue.offer(
+ input,
+ yield* encodeJsonl(jsonRpcRequest(method, AcpSchema.ElicitationRequest), {
+ jsonrpc: "2.0",
+ id: 71,
+ headers: [],
+ method,
+ params: elicitationForm,
+ }),
+ );
+ const received = yield* decodeWireResponse(yield* Queue.take(output));
+ assert.deepEqual(received, {
+ jsonrpc: "2.0",
+ id: 71,
+ result: method === "elicitation/create" ? { ...answer, _meta: response._meta } : response,
+ });
+ }
+ }).pipe(Effect.scoped),
+);
+
+it.effect("dispatches SDK URL elicitation to the same registered handler", () =>
+ Effect.gen(function* () {
+ const { stdio, input, output } = yield* makeInMemoryStdio();
+ const acp = yield* AcpClient.make(stdio);
+ const params = {
+ sessionId: "session-1",
+ mode: "url" as const,
+ message: "Sign in",
+ elicitationId: "elicitation-1",
+ url: "https://example.test/sign-in",
+ };
+ yield* acp.handleElicitation((request) => {
+ assert.deepEqual(request, params);
+ return Effect.succeed({ action: { action: "accept" as const } });
+ });
+ yield* Queue.offer(
+ input,
+ yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.ElicitationRequest), {
+ jsonrpc: "2.0",
+ id: 72,
+ headers: [],
+ method: "elicitation/create",
+ params,
+ }),
+ );
+ assert.deepEqual(yield* decodeWireResponse(yield* Queue.take(output)), {
+ jsonrpc: "2.0",
+ id: 72,
+ result: { action: "accept" },
+ });
+ }).pipe(Effect.scoped),
+);
+
+it.effect.each(["elicitation/complete", "session/elicitation/complete"])(
+ "delivers %s notifications to the completion handler",
+ (method) =>
+ Effect.gen(function* () {
+ const { stdio, input } = yield* makeInMemoryStdio();
+ const acp = yield* AcpClient.make(stdio);
+ const received = yield* Deferred.make();
+ yield* acp.handleElicitationComplete((notification) =>
+ Deferred.succeed(received, notification).pipe(Effect.asVoid),
+ );
+ yield* Queue.offer(
+ input,
+ yield* encodeJsonl(jsonRpcNotification(method, AcpSchema.ElicitationCompleteNotification), {
+ jsonrpc: "2.0",
+ method,
+ params: { elicitationId: "elicitation-1" },
+ }),
+ );
+ assert.deepEqual(yield* Deferred.await(received), { elicitationId: "elicitation-1" });
+ }).pipe(Effect.scoped),
+);
+
+it.effect("rejects malformed SDK elicitation without calling the question handler", () =>
+ Effect.gen(function* () {
+ const { stdio, input, output } = yield* makeInMemoryStdio();
+ const acp = yield* AcpClient.make(stdio);
+ let calls = 0;
+ yield* acp.handleElicitation(() => {
+ calls++;
+ return Effect.succeed({ action: { action: "cancel" as const } });
+ });
+ yield* Queue.offer(
+ input,
+ yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), {
+ jsonrpc: "2.0",
+ id: 73,
+ headers: [],
+ method: "elicitation/create",
+ params: { ...elicitationForm, message: 123 },
+ }),
+ );
+ const response = yield* decodeWireError(yield* Queue.take(output));
+ assert.equal(response.error.code, -32602);
+ assert.equal(calls, 0);
+ }).pipe(Effect.scoped),
+);
+
+it.effect("returns method-not-found when SDK elicitation has no question handler", () =>
+ Effect.gen(function* () {
+ const { stdio, input, output } = yield* makeInMemoryStdio();
+ yield* AcpClient.make(stdio);
+ yield* Queue.offer(
+ input,
+ yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.ElicitationRequest), {
+ jsonrpc: "2.0",
+ id: 74,
+ headers: [],
+ method: "elicitation/create",
+ params: elicitationForm,
+ }),
+ );
+ const response = yield* decodeWireError(yield* Queue.take(output));
+ assert.equal(response.error.code, -32601);
+ }).pipe(Effect.scoped),
+);
diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts
index b3bcf3cd2433..27203c20c627 100644
--- a/packages/effect-acp/src/client.ts
+++ b/packages/effect-acp/src/client.ts
@@ -22,6 +22,8 @@ import {
} from "./_internal/shared.ts";
import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";
+const decodeElicitationRequest = Schema.decodeUnknownEffect(AcpSchema.ElicitationRequest);
+
export interface AcpClientOptions {
readonly logIncoming?: boolean;
readonly logOutgoing?: boolean;
@@ -151,7 +153,7 @@ export class AcpClient extends Context.Service<
) => Effect.Effect,
) => Effect.Effect;
/**
- * Registers a handler for `session/elicitation`.
+ * Registers a handler for `session/elicitation` and `elicitation/create`.
* @see https://agentclientprotocol.com/protocol/schema#session/elicitation
*/
readonly handleElicitation: (
@@ -232,7 +234,7 @@ export class AcpClient extends Context.Service<
) => Effect.Effect,
) => Effect.Effect;
/**
- * Registers a handler for `session/elicitation/complete`.
+ * Registers a handler for `session/elicitation/complete` and `elicitation/complete`.
* @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete
*/
readonly handleElicitationComplete: (
@@ -431,6 +433,22 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
),
[CLIENT_METHODS.session_elicitation]: (payload) =>
runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation),
+ "elicitation/create": (payload) =>
+ decodeElicitationRequest(payload).pipe(
+ Effect.mapError((cause) =>
+ AcpError.AcpRequestError.invalidExtensionPayload(
+ "elicitation/create",
+ cause,
+ ).toProtocolError(),
+ ),
+ Effect.flatMap((request) =>
+ runHandler(coreHandlers.elicitation, request, "elicitation/create"),
+ ),
+ Effect.map(({ action, _meta }) => ({
+ ...action,
+ ...(_meta !== undefined ? { _meta } : {}),
+ })),
+ ),
[CLIENT_METHODS.fs_read_text_file]: (payload) =>
runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file),
[CLIENT_METHODS.fs_write_text_file]: (payload) =>
diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts
index cd6043f2db00..f74a8ea29e37 100644
--- a/packages/effect-acp/src/protocol.ts
+++ b/packages/effect-acp/src/protocol.ts
@@ -34,7 +34,7 @@ export type AcpIncomingNotification =
}
| {
readonly _tag: "ElicitationComplete";
- readonly method: typeof CLIENT_METHODS.session_elicitation_complete;
+ readonly method: typeof CLIENT_METHODS.session_elicitation_complete | "elicitation/complete";
readonly params: AcpSchema.ElicitationCompleteNotification;
}
| {
@@ -316,20 +316,24 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
Effect.flatMap(dispatchNotification),
);
}
- if (message.tag === CLIENT_METHODS.session_elicitation_complete) {
+ if (
+ message.tag === CLIENT_METHODS.session_elicitation_complete ||
+ message.tag === "elicitation/complete"
+ ) {
+ const method = message.tag;
return decodeElicitationComplete(message.payload).pipe(
Effect.map(
(params) =>
({
_tag: "ElicitationComplete",
- method: CLIENT_METHODS.session_elicitation_complete,
+ method,
params,
}) satisfies AcpIncomingNotification,
),
Effect.mapError((cause) =>
AcpError.AcpProtocolParseError.fromSchemaError(
"decode-notification-payload",
- CLIENT_METHODS.session_elicitation_complete,
+ method,
cause,
),
),
diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts
index 5026645374eb..118e5e377659 100644
--- a/packages/effect-acp/src/rpc.ts
+++ b/packages/effect-acp/src/rpc.ts
@@ -1,5 +1,6 @@
import * as Rpc from "effect/unstable/rpc/Rpc";
import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
+import * as Schema from "effect/Schema";
import * as AcpSchema from "./_generated/schema.gen.ts";
import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
@@ -100,6 +101,20 @@ const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, {
error: AcpSchema.Error,
});
+// The pinned v0.11.3 schema predates the SDK's method name and flat response.
+// Keep its RPC for existing peers and translate the SDK alias at the boundary.
+const CreateElicitationRpc = Rpc.make("elicitation/create", {
+ payload: Schema.Unknown,
+ success: Schema.Struct({
+ action: Schema.Literals(["accept", "decline", "cancel"]),
+ content: Schema.optionalKey(
+ Schema.NullOr(Schema.Record(Schema.String, AcpSchema.ElicitationContentValue)),
+ ),
+ _meta: AcpSchema.ElicitationResponse.fields._meta,
+ }),
+ error: AcpSchema.Error,
+});
+
const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, {
payload: AcpSchema.CreateTerminalRequest,
success: AcpSchema.CreateTerminalResponse,
@@ -150,6 +165,7 @@ export const ClientRpcs = RpcGroup.make(
WriteTextFileRpc,
RequestPermissionRpc,
ElicitationRpc,
+ CreateElicitationRpc,
CreateTerminalRpc,
TerminalOutputRpc,
ReleaseTerminalRpc,
From bd0b9edac3cf8fe30f8242dd7943a9240810b2c9 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 17:24:23 -0700
Subject: [PATCH 036/111] fix(release): read relay configuration without
loading deployment providers (#12518)
---
.github/workflows/release.yml | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 381a6b2b9b13..5e227d00ebcf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -312,14 +312,16 @@ jobs:
- --filter=t3code-relay...
# The deployed stack's outputs, read from Alchemy's state store without
- # planning or applying. Redacted values are persisted as
+ # planning or applying. Select the backend explicitly to avoid loading
+ # the stack's unrelated provider credentials; suppress informational logs
+ # so stdout stays valid JSON. Redacted values are persisted as
# {"__redacted__": ""}; the token is masked before it is written.
- name: Read production relay tracing config
shell: bash
working-directory: infra/relay
run: |
set -euo pipefail
- output="$(npx alchemy state read T3CodeRelay/prod/output --no-input)"
+ output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input)"
field() {
jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \
|| { echo "Relay stack output is missing $1" >&2; exit 1; }
From 4931d73af3e68d8706d7568b9af37c8397caffdb Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 17:34:13 -0700
Subject: [PATCH 037/111] fix(ci): reconcile native change labels against
pinned commits (#12517)
---
.../workflows/mobile-fingerprint-check.yml | 47 ++++++++++++-------
1 file changed, 31 insertions(+), 16 deletions(-)
diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml
index fd98817cd105..b8b872f019f6 100644
--- a/.github/workflows/mobile-fingerprint-check.yml
+++ b/.github/workflows/mobile-fingerprint-check.yml
@@ -13,17 +13,9 @@ name: Mobile Fingerprint Check
# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed.
on:
pull_request:
- paths:
- - apps/mobile/**
- - packages/client-runtime/**
- - packages/contracts/**
- - packages/shared/**
- - assets/**
- - scripts/**
- - patches/**
- - pnpm-lock.yaml
- - pnpm-workspace.yaml
- - .github/workflows/mobile-fingerprint-check.yml
+ # Run even when a rebase or base change removes every native input from the
+ # diff, so the label can be cleared without installing Expo dependencies.
+ types: [opened, synchronize, reopened, edited]
concurrency:
group: mobile-fingerprint-check-${{ github.event.pull_request.number }}
@@ -44,12 +36,29 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
- # Default pull_request checkout is the merge commit (PR applied on
- # top of base), so the "head" fingerprint is the state main would
- # actually be in after merging — stale branches compare cleanly.
- fetch-depth: 0
+ # The merge ref can advance after the event is queued. Pin its commit
+ # and compare against its first parent, rather than the event's base.
+ ref: ${{ github.sha }}
+ fetch-depth: 2
+
+ - id: changes
+ name: Detect native fingerprint inputs
+ run: |
+ base_sha=$(git rev-parse HEAD^1)
+ echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
+ paths=$(git diff --no-renames --name-only "$base_sha" HEAD -- \
+ apps/mobile/ packages/client-runtime/ packages/contracts/ packages/shared/ \
+ assets/ scripts/ patches/ package.json pnpm-lock.yaml pnpm-workspace.yaml \
+ .github/workflows/mobile-fingerprint-check.yml)
+ if [[ -n "$paths" ]]; then
+ echo "relevant=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "relevant=false" >> "$GITHUB_OUTPUT"
+ echo "No native fingerprint inputs changed; clearing any stale native change label." >> "$GITHUB_STEP_SUMMARY"
+ fi
- name: Setup Vite+
+ if: steps.changes.outputs.relevant == 'true'
uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
@@ -59,6 +68,7 @@ jobs:
- --filter=@t3tools/mobile...
- name: Expose pnpm
+ if: steps.changes.outputs.relevant == 'true'
run: |
pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
@@ -66,6 +76,7 @@ jobs:
"$vp_pnpm_bin/pnpm" --version
- name: Fingerprint merge result
+ if: steps.changes.outputs.relevant == 'true'
working-directory: apps/mobile
run: |
mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base"
@@ -74,8 +85,11 @@ jobs:
done
- name: Fingerprint base
+ if: steps.changes.outputs.relevant == 'true'
+ env:
+ BASE_SHA: ${{ steps.changes.outputs.base_sha }}
run: |
- git checkout --quiet "${{ github.event.pull_request.base.sha }}"
+ git checkout --quiet "$BASE_SHA"
# Re-sync node_modules to the base commit's lockfile before
# fingerprinting — a dep-changing PR must not fingerprint the base
# against head's installed packages.
@@ -87,6 +101,7 @@ jobs:
- id: compare
name: Compare fingerprints
+ if: steps.changes.outputs.relevant == 'true'
run: |
changed=""
{
From e74c668d786ffc14f40a9540cd3aede02ba5d62e Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 18 Sep 2026 17:44:23 -0700
Subject: [PATCH 038/111] fix(release): strip Alchemy progress before parsing
relay state (#12519)
---
.github/scripts/relay-state-output.test.cjs | 76 +++++++++++++++++++++
.github/workflows/release.yml | 5 +-
scripts/release-smoke.ts | 6 ++
3 files changed, 85 insertions(+), 2 deletions(-)
create mode 100644 .github/scripts/relay-state-output.test.cjs
diff --git a/.github/scripts/relay-state-output.test.cjs b/.github/scripts/relay-state-output.test.cjs
new file mode 100644
index 000000000000..fcb829dd91e8
--- /dev/null
+++ b/.github/scripts/relay-state-output.test.cjs
@@ -0,0 +1,76 @@
+const assert = require("node:assert/strict");
+const { spawnSync } = require("node:child_process");
+const { mkdtempSync, readFileSync, rmSync, existsSync } = require("node:fs");
+const { tmpdir } = require("node:os");
+const { join } = require("node:path");
+const { test } = require("node:test");
+
+const workflow = readFileSync(join(__dirname, "../workflows/release.yml"), "utf8");
+const step = workflow.match(
+ /- name: Read production relay tracing config\n[\s\S]*? run: \|\n((?: .*\n|\n)+)/,
+);
+assert.ok(step, "Could not find the relay state workflow step");
+const script = step[1].replace(/^ /gm, "");
+const config = {
+ clientTracingUrl: "https://example.invalid/traces",
+ clientTracingDataset: "fixture-dataset",
+ clientTracingToken: { __redacted__: "fixture-token" },
+};
+const json = JSON.stringify(config, null, 2);
+
+function runStep(stdout, exitCode = 0) {
+ const runnerTemp = mkdtempSync(join(tmpdir(), "t3-relay-state-test-"));
+ try {
+ const result = spawnSync(
+ "bash",
+ ["-c", 'npx() { printf "%s\\n" "$FIXTURE_STDOUT"; return "$FIXTURE_EXIT"; }\n' + script],
+ {
+ encoding: "utf8",
+ env: {
+ PATH: process.env.PATH,
+ RUNNER_TEMP: runnerTemp,
+ FIXTURE_STDOUT: stdout,
+ FIXTURE_EXIT: String(exitCode),
+ },
+ },
+ );
+ assert.ifError(result.error);
+ const envPath = join(runnerTemp, "relay-client-tracing.env");
+ return {
+ ...result,
+ envFile: existsSync(envPath) ? readFileSync(envPath, "utf8") : undefined,
+ };
+ } finally {
+ rmSync(runnerTemp, { recursive: true, force: true });
+ }
+}
+
+for (const prefix of [
+ "",
+ "• Refreshing Cloudflare State Store credentials\n✓ Refreshing Cloudflare State Store credentials\n",
+]) {
+ test(`extracts tracing config ${prefix ? "after progress output" : "from plain JSON"}`, () => {
+ const result = runStep(prefix + json);
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.stdout, "::add-mask::fixture-token\n");
+ assert.equal(
+ result.envFile,
+ "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://example.invalid/traces\n" +
+ "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=fixture-dataset\n" +
+ "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=fixture-token\n",
+ );
+ });
+}
+
+for (const [name, stdout, exitCode] of [
+ ["failed CLI even with valid JSON", json, 1],
+ ["missing JSON", "Refreshing credentials...", 0],
+ ["malformed JSON", "{not JSON", 0],
+ ["missing token", JSON.stringify({ ...config, clientTracingToken: null }, null, 2), 0],
+]) {
+ test(`rejects ${name} without writing config`, () => {
+ const result = runStep(stdout, exitCode);
+ assert.notEqual(result.status, 0);
+ assert.equal(result.envFile, undefined);
+ });
+}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5e227d00ebcf..8890de34e721 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -314,14 +314,15 @@ jobs:
# The deployed stack's outputs, read from Alchemy's state store without
# planning or applying. Select the backend explicitly to avoid loading
# the stack's unrelated provider credentials; suppress informational logs
- # so stdout stays valid JSON. Redacted values are persisted as
+ # and strip the credential-refresh progress prefix before parsing JSON.
+ # Redacted values are persisted as
# {"__redacted__": ""}; the token is masked before it is written.
- name: Read production relay tracing config
shell: bash
working-directory: infra/relay
run: |
set -euo pipefail
- output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input)"
+ output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')"
field() {
jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \
|| { echo "Relay stack output is missing $1" >&2; exit 1; }
diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts
index 45d2dd436b6a..e33116bb1dbf 100644
--- a/scripts/release-smoke.ts
+++ b/scripts/release-smoke.ts
@@ -188,6 +188,12 @@ function assertMissing(path: string, message: string): void {
const tempRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-release-smoke-"));
try {
+ NodeChildProcess.execFileSync(
+ process.execPath,
+ ["--test", NodePath.resolve(repoRoot, ".github/scripts/relay-state-output.test.cjs")],
+ { stdio: "inherit" },
+ );
+
copyWorkspaceManifestFixture(tempRoot);
NodeChildProcess.execFileSync(
From 9cb586acd0494c4f47f60045ee42ef25edb5a07c Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Fri, 18 Sep 2026 18:23:56 -0700
Subject: [PATCH 039/111] refactor: remove obsolete code (#9917)
Co-authored-by: Julius Marminge
---
.../src/app/DesktopAppIdentity.test.ts | 1 -
.../app/DesktopConnectionCatalogStore.test.ts | 34 +-
apps/desktop/src/app/DesktopLifecycle.test.ts | 1 -
.../src/backend/DesktopBackendManager.test.ts | 6 +-
.../src/backend/DesktopBackendPool.test.ts | 1 -
apps/desktop/src/electron/ElectronApp.test.ts | 3 -
apps/desktop/src/electron/ElectronApp.ts | 3 -
.../settings/DesktopSavedEnvironments.test.ts | 206 +---
.../src/settings/DesktopSavedEnvironments.ts | 273 +-----
.../src/shell/DesktopShellEnvironment.test.ts | 10 -
.../src/shell/DesktopShellEnvironment.ts | 24 -
apps/desktop/src/ssh/DesktopSshEnvironment.ts | 4 -
.../src/ssh/DesktopSshPasswordPrompts.ts | 4 -
.../DesktopTelemetryPublisher.test.ts | 9 +-
.../telemetry/DesktopTelemetryPublisher.ts | 4 -
.../src/updates/DesktopRemoteUpdates.test.ts | 1 -
.../src/window/DesktopApplicationMenu.test.ts | 1 -
.../t3-review-diff/ios/T3ReviewDiffView.swift | 20 -
.../features/cloud/linkEnvironment.test.ts | 897 +-----------------
.../src/features/cloud/linkEnvironment.ts | 343 +------
apps/mobile/src/features/review/reviewPerf.ts | 27 -
.../features/review/shikiReviewHighlighter.ts | 55 +-
.../OrchestrationEngineHarness.integration.ts | 9 -
.../orchestrationEngine.integration.test.ts | 29 +-
apps/server/src/orchestration/Errors.ts | 45 +-
.../Layers/ProjectionSnapshotQuery.ts | 17 +-
apps/server/src/persistence/Errors.test.ts | 2 +-
apps/server/src/persistence/Errors.ts | 34 -
.../Layers/ProjectionCheckpoints.ts | 217 -----
.../Layers/ProjectionPendingApprovals.ts | 20 -
.../persistence/Layers/ProjectionProjects.ts | 47 -
.../Layers/ProjectionRepositories.test.ts | 3 -
.../src/persistence/Layers/ProjectionState.ts | 25 -
.../persistence/Layers/ProjectionThreads.ts | 66 --
.../016_CanonicalizeModelSelections.test.ts | 2 +-
...19_ProjectionSnapshotLookupIndexes.test.ts | 2 +-
...ckfillProjectionThreadShellSummary.test.ts | 2 +-
...pInvalidProjectionPendingApprovals.test.ts | 2 +-
..._CanonicalizeModelSelectionOptions.test.ts | 2 +-
.../027_028_ProviderInstanceIdColumns.test.ts | 2 +-
...jectionThreadDetailOrderingIndexes.test.ts | 2 +-
.../031_AuthAuthorizationScopes.test.ts | 2 +-
..._ProjectionThreadTitleRegeneration.test.ts | 2 +-
.../040_ProjectionProjectFaviconPath.test.ts | 2 +-
.../041_AuthSessionClientConnection.test.ts | 2 +-
..._ProjectionThreadLinkedPullRequest.test.ts | 2 +-
...epairAutomaticSettlementTimestamps.test.ts | 2 +-
.../047_ProjectionProjectIcon.test.ts | 2 +-
...49_ProjectionThreadsActiveOrderKey.test.ts | 49 +-
.../050_ProjectionThreadPullRequests.test.ts | 41 +-
...051_ProjectionThreadMessageContext.test.ts | 2 +-
.../Services/ProjectionCheckpoints.ts | 95 --
.../Services/ProjectionPendingApprovals.ts | 12 -
.../Services/ProjectionProjects.ts | 22 -
.../persistence/Services/ProjectionState.ts | 7 -
.../persistence/Services/ProjectionThreads.ts | 26 -
.../src/provider/acp/XAiAcpExtension.ts | 4 -
apps/server/src/server.test.ts | 21 +-
.../src/textGeneration/TextGeneration.ts | 2 -
.../src/workspace/WorkspaceSearchIndex.ts | 6 -
apps/web/src/cloud/managedAuth.test.ts | 9 +-
apps/web/src/cloud/managedAuth.tsx | 8 -
.../web/src/components/ChatView.logic.test.ts | 46 -
apps/web/src/components/ChatView.logic.ts | 30 +-
.../preview/PreviewPanelShell.test.ts | 12 +-
.../settings/settingsLayout.test.tsx | 20 +-
.../components/settings/settingsLayout.tsx | 2 +-
apps/web/src/components/ui/alert-dialog.tsx | 8 -
apps/web/src/components/ui/autocomplete.tsx | 40 -
apps/web/src/components/ui/combobox.tsx | 146 ---
apps/web/src/components/ui/command.tsx | 30 -
apps/web/src/components/ui/dialog.tsx | 3 -
apps/web/src/components/ui/group.tsx | 20 -
apps/web/src/components/ui/input-group.tsx | 19 +-
apps/web/src/components/ui/menu.tsx | 8 -
apps/web/src/components/ui/number-field.tsx | 73 +-
apps/web/src/components/ui/popover.tsx | 11 -
apps/web/src/components/ui/select.tsx | 52 +-
apps/web/src/components/ui/sheet.tsx | 54 --
apps/web/src/components/ui/sidebar.test.tsx | 86 +-
apps/web/src/components/ui/sidebar.tsx | 133 ---
apps/web/src/components/ui/table.tsx | 22 +-
apps/web/src/components/ui/toggle-group.tsx | 22 +-
apps/web/src/components/ui/tooltip.tsx | 4 +-
apps/web/src/providerInstances.test.ts | 39 -
apps/web/src/providerInstances.ts | 18 -
apps/web/src/types.ts | 5 -
apps/web/src/vite-plus-browser-matchers.d.ts | 11 -
.../AgentActivityPublisher.test.ts | 2 -
infra/relay/src/agentActivity/ApnsClient.ts | 9 +-
.../src/agentActivity/FcmDeliveries.test.ts | 2 -
.../agentActivity/MobileRegistrations.test.ts | 2 -
.../environments/EnvironmentConnector.test.ts | 2 -
.../environments/EnvironmentLinker.test.ts | 2 -
.../src/environments/EnvironmentLinks.test.ts | 14 +-
.../src/environments/EnvironmentLinks.ts | 86 +-
infra/relay/src/http/Api.test.ts | 2 -
infra/relay/src/http/Api.ts | 1 -
.../src/authorization/remote.test.ts | 40 -
.../src/authorization/remote.ts | 21 -
packages/contracts/src/environment.ts | 14 -
packages/contracts/src/ipc.ts | 254 +----
packages/contracts/src/orchestration.ts | 20 -
.../contracts/src/providerRuntime.test.ts | 12 +-
packages/contracts/src/providerRuntime.ts | 53 --
.../scripts/generate.ts | 6 +-
packages/shared/src/nodeSqliteClient.test.ts | 2 +-
packages/shared/src/nodeSqliteClient.ts | 48 +-
packages/shared/src/shell.ts | 1 -
packages/tailscale/src/tailscale.ts | 12 +-
scripts/dev-runner.ts | 11 -
scripts/lib/dev-share.ts | 11 +-
scripts/mobile-showcase.ts | 13 -
scripts/notify-discord-release.test.ts | 8 +-
scripts/notify-discord-release.ts | 7 -
scripts/sync-reference-repos.ts | 13 +-
116 files changed, 260 insertions(+), 4095 deletions(-)
delete mode 100644 apps/server/src/persistence/Layers/ProjectionCheckpoints.ts
delete mode 100644 apps/server/src/persistence/Services/ProjectionCheckpoints.ts
delete mode 100644 apps/web/src/vite-plus-browser-matchers.d.ts
diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts
index 71bcf5f7aef1..ce0c7d013a99 100644
--- a/apps/desktop/src/app/DesktopAppIdentity.test.ts
+++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts
@@ -57,7 +57,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
}),
setAppUserModelId: () => Effect.void,
getAppMetrics: Effect.succeed([]),
- isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: (iconPath) =>
diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
index aa28ff8d86eb..fdc69841343c 100644
--- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
+++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
@@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest";
import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform";
import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as Encoding from "effect/Encoding";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Layer from "effect/Layer";
@@ -22,6 +23,11 @@ const textEncoder = new TextEncoder();
const decodeConnectionCatalog = Schema.decodeEffect(
Schema.fromJsonString(ConnectionCatalogDocument),
);
+const encodeLegacySavedEnvironments = Schema.encodeEffect(
+ Schema.fromJsonString(
+ Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }),
+ ),
+);
function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref | null = null) {
return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
isEncryptionAvailable: Effect.succeed(available),
@@ -125,7 +131,8 @@ describe("DesktopConnectionCatalogStore", () => {
withStore(
Effect.gen(function* () {
const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore;
- const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
const records: readonly PersistedSavedEnvironmentRecord[] = [
{
environmentId: EnvironmentId.make("relay-environment"),
@@ -159,11 +166,21 @@ describe("DesktopConnectionCatalogStore", () => {
lastConnectedAt: null,
},
];
- yield* savedEnvironments.setRegistry(records);
- assert.isTrue(
- yield* savedEnvironments.setSecret({
- environmentId: EnvironmentId.make("bearer-environment"),
- secret: "legacy-token",
+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
+ yield* fileSystem.writeFileString(
+ environment.savedEnvironmentRegistryPath,
+ yield* encodeLegacySavedEnvironments({
+ version: 1,
+ records: records.map((record) =>
+ record.environmentId === "bearer-environment"
+ ? {
+ ...record,
+ encryptedBearerToken: Encoding.encodeBase64(
+ textEncoder.encode("encrypted:legacy-token"),
+ ),
+ }
+ : record,
+ ),
}),
);
@@ -218,7 +235,10 @@ describe("DesktopConnectionCatalogStore", () => {
assert.equal(catalog.credentials[0].credential.token, "legacy-token");
}
- yield* savedEnvironments.setRegistry([]);
+ yield* fileSystem.writeFileString(
+ environment.savedEnvironmentRegistryPath,
+ '{"version":1,"records":[]}',
+ );
assert.deepEqual(yield* store.get, migrated);
}),
),
diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts
index 515be6726c94..33c74f5a8b9b 100644
--- a/apps/desktop/src/app/DesktopLifecycle.test.ts
+++ b/apps/desktop/src/app/DesktopLifecycle.test.ts
@@ -43,7 +43,6 @@ function makeElectronAppLayer(
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
getAppMetrics: Effect.succeed([]),
- isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts
index 5a1ef70ad1c2..df2001f1ac20 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts
@@ -165,9 +165,7 @@ function makeTestInstance(input: MakeInstanceInput) {
latest: Effect.succeed(Option.none()),
changes: Stream.empty,
encoded: input.desktopTelemetryStream ?? Stream.empty,
- handleControl: () => Effect.void,
- handleControlForSource: (_sourceId, message) =>
- (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message),
+ handleControlForSource: () => Effect.void,
removeControlSource: () => Effect.void,
publishUpdateReport: () => Effect.void,
updateRequests: Stream.empty,
@@ -596,7 +594,7 @@ describe("DesktopBackendManager", () => {
const instance = yield* makeTestInstance({
spawnerLayer,
desktopTelemetryPublisher: {
- handleControl: (message) =>
+ handleControlForSource: (_sourceId, message) =>
message.type === "setDiagnosticsDemand"
? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid)
: Effect.void,
diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts
index b9a96d72a77a..7859223161b7 100644
--- a/apps/desktop/src/backend/DesktopBackendPool.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts
@@ -70,7 +70,6 @@ function makePoolLayer(
latest: Effect.succeed(Option.none()),
changes: Stream.empty,
encoded: Stream.empty,
- handleControl: () => Effect.void,
handleControlForSource: () => Effect.void,
removeControlSource: () => Effect.void,
publishUpdateReport: () => Effect.void,
diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts
index 4189ea793e2d..84f37b6f5f53 100644
--- a/apps/desktop/src/electron/ElectronApp.test.ts
+++ b/apps/desktop/src/electron/ElectronApp.test.ts
@@ -10,7 +10,6 @@ const {
getAppPathMock,
getSystemLocaleMock,
getVersionMock,
- isDefaultProtocolClientMock,
onMock,
quitMock,
relaunchMock,
@@ -32,7 +31,6 @@ const {
getAppPathMock: vi.fn(() => "/app"),
getSystemLocaleMock: vi.fn(() => "en-GB"),
getVersionMock: vi.fn(() => "1.2.3"),
- isDefaultProtocolClientMock: vi.fn(() => false),
onMock: vi.fn(),
quitMock: vi.fn(),
relaunchMock: vi.fn(),
@@ -64,7 +62,6 @@ vi.mock("electron", () => ({
getAppPath: getAppPathMock,
getSystemLocale: getSystemLocaleMock,
getVersion: getVersionMock,
- isDefaultProtocolClient: isDefaultProtocolClientMock,
isPackaged: true,
name: "T3 Code",
on: onMock,
diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts
index b54da535add0..f75ea51552e7 100644
--- a/apps/desktop/src/electron/ElectronApp.ts
+++ b/apps/desktop/src/electron/ElectronApp.ts
@@ -64,7 +64,6 @@ export class ElectronApp extends Context.Service<
) => Effect.Effect;
readonly setAppUserModelId: (id: string) => Effect.Effect;
readonly getAppMetrics: Effect.Effect>;
- readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect;
readonly setAsDefaultProtocolClient: (
protocol: string,
path?: string,
@@ -166,8 +165,6 @@ export const make = ElectronApp.of({
Electron.app.setAppUserModelId(id);
}),
getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()),
- isDefaultProtocolClient: (protocol) =>
- Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
setAsDefaultProtocolClient: (protocol, path, args) =>
Effect.sync(() => {
if (path === undefined) {
diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
index 348f6cb3843e..dad53815f3a6 100644
--- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
+++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts
@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as Encoding from "effect/Encoding";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Layer from "effect/Layer";
@@ -39,16 +40,29 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({
const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString(
SavedEnvironmentRegistryDocumentProbe,
);
-const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect(
- SavedEnvironmentRegistryDocumentProbeJson,
-);
const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect(
SavedEnvironmentRegistryDocumentProbeJson,
);
+
+const seedSavedEnvironmentRegistry = Effect.fn(function* (encryptedBearerToken?: string) {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
+ const fileSystem = yield* FileSystem.FileSystem;
+ yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
+ const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
+ version: 1,
+ records: [
+ {
+ ...savedRegistryRecord,
+ ...(encryptedBearerToken === undefined ? {} : { encryptedBearerToken }),
+ },
+ ],
+ });
+ yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
+});
+
function makeSafeStorageLayer(input: {
readonly available: boolean;
readonly availabilityError?: unknown;
- readonly encryptError?: unknown;
readonly decryptError?: unknown;
}) {
return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
@@ -60,14 +74,7 @@ function makeSafeStorageLayer(input: {
cause: input.availabilityError,
}),
),
- encryptString: (value) =>
- input.encryptError === undefined
- ? Effect.succeed(textEncoder.encode(`enc:${value}`))
- : Effect.fail(
- new ElectronSafeStorage.ElectronSafeStorageEncryptError({
- cause: input.encryptError,
- }),
- ),
+ encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)),
decryptString: (value) => {
if (input.decryptError !== undefined) {
return Effect.fail(
@@ -96,7 +103,6 @@ function makeLayer(
options?: {
readonly availableSecretStorage?: boolean;
readonly availabilityError?: unknown;
- readonly encryptError?: unknown;
readonly decryptError?: unknown;
},
fileSystemLayer: Layer.Layer = NodeServices.layer,
@@ -120,7 +126,6 @@ function makeLayer(
const safeStorageLayer = makeSafeStorageLayer({
available: options?.availableSecretStorage ?? true,
availabilityError: options?.availabilityError,
- encryptError: options?.encryptError,
decryptError: options?.decryptError,
});
const dependencies = Layer.mergeAll(
@@ -138,7 +143,6 @@ const withSavedEnvironments = (
options?: {
readonly availableSecretStorage?: boolean;
readonly availabilityError?: unknown;
- readonly encryptError?: unknown;
readonly decryptError?: unknown;
},
) =>
@@ -151,20 +155,13 @@ const withSavedEnvironments = (
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
describe("DesktopSavedEnvironments", () => {
- it.effect("persists and reloads saved environment metadata", () =>
+ it.effect("reads saved environment metadata", () =>
withSavedEnvironments(
Effect.gen(function* () {
- const environment = yield* DesktopEnvironment.DesktopEnvironment;
- const fileSystem = yield* FileSystem.FileSystem;
const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
+ yield* seedSavedEnvironmentRegistry();
assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
- const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe(
- yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath),
- );
- assert.equal(persisted.version, 1);
- assert.lengthOf(persisted.records, 1);
}),
),
);
@@ -205,17 +202,12 @@ describe("DesktopSavedEnvironments", () => {
),
);
- it.effect("persists encrypted saved environment secrets when encryption is available", () =>
+ it.effect("reads encrypted saved environment secrets when encryption is available", () =>
withSavedEnvironments(
Effect.gen(function* () {
const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
-
- assert.isTrue(
- yield* savedEnvironments.setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "bearer-token",
- }),
+ yield* seedSavedEnvironmentRegistry(
+ Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
);
assert.deepEqual(
@@ -230,14 +222,8 @@ describe("DesktopSavedEnvironments", () => {
withSavedEnvironments(
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
- const fileSystem = yield* FileSystem.FileSystem;
const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
- const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({
- version: 1,
- records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }],
- });
- yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`);
+ yield* seedSavedEnvironmentRegistry("%%%");
const error = yield* savedEnvironments
.getSecret(savedRegistryRecord.environmentId)
@@ -256,17 +242,17 @@ describe("DesktopSavedEnvironments", () => {
),
);
- it.effect("returns false when writing secrets while encryption is unavailable", () =>
+ it.effect("returns no secret while encryption is unavailable", () =>
withSavedEnvironments(
Effect.gen(function* () {
const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
+ yield* seedSavedEnvironmentRegistry(
+ Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
+ );
- assert.isFalse(
- yield* savedEnvironments.setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "next-token",
- }),
+ assert.deepEqual(
+ yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
+ Option.none(),
);
}),
{ availableSecretStorage: false },
@@ -279,13 +265,12 @@ describe("DesktopSavedEnvironments", () => {
Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
+ yield* seedSavedEnvironmentRegistry(
+ Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")),
+ );
const error = yield* savedEnvironments
- .setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "next-token",
- })
+ .getSecret(savedRegistryRecord.environmentId)
.pipe(Effect.flip);
assert.instanceOf(
@@ -309,45 +294,6 @@ describe("DesktopSavedEnvironments", () => {
);
});
- it.effect("removes saved environment secrets", () =>
- withSavedEnvironments(
- Effect.gen(function* () {
- const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
- yield* savedEnvironments.setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "bearer-token",
- });
-
- yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId);
-
- assert.isTrue(
- Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
- );
- }),
- ),
- );
-
- it.effect("removes saved environment metadata and its embedded secret atomically", () =>
- withSavedEnvironments(
- Effect.gen(function* () {
- const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
- yield* savedEnvironments.setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "bearer-token",
- });
-
- yield* savedEnvironments.removeEnvironment(savedRegistryRecord.environmentId);
-
- assert.deepEqual(yield* savedEnvironments.getRegistry, []);
- assert.isTrue(
- Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)),
- );
- }),
- ),
- );
-
it.effect("treats empty saved environment documents as empty", () =>
withSavedEnvironments(
Effect.gen(function* () {
@@ -388,13 +334,6 @@ describe("DesktopSavedEnvironments", () => {
secretError,
DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
);
- const mutationError = yield* savedEnvironments
- .setRegistry([savedRegistryRecord])
- .pipe(Effect.flip);
- assert.instanceOf(
- mutationError,
- DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError,
- );
}),
),
);
@@ -431,77 +370,4 @@ describe("DesktopSavedEnvironments", () => {
assert.notEqual(error.message, permissionError.message);
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);
-
- it.effect("reports the failed saved environment write operation and path", () =>
- Effect.gen(function* () {
- const baseFileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({
- prefix: "t3-desktop-saved-environments-test-",
- });
- const permissionError = PlatformError.systemError({
- _tag: "PermissionDenied",
- module: "FileSystem",
- method: "makeDirectory",
- pathOrDescriptor: path.join(baseDir, "userdata"),
- });
- const fileSystemLayer = Layer.succeed(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- readFileString: baseFileSystem.readFileString,
- makeDirectory: () => Effect.fail(permissionError),
- }),
- );
- const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe(
- Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)),
- );
-
- const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip);
- assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError);
- assert.equal(error.operation, "create-directory");
- assert.equal(error.path, path.join(baseDir, "userdata"));
- assert.strictEqual(error.cause, permissionError);
- assert.equal(
- error.message,
- `Desktop saved-environment write failed during create-directory at ${path.join(baseDir, "userdata")}.`,
- );
- assert.notEqual(error.message, permissionError.message);
- }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
- );
-
- it.effect("returns false when writing a secret without metadata", () =>
- withSavedEnvironments(
- Effect.gen(function* () {
- const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
-
- assert.isFalse(
- yield* savedEnvironments.setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "bearer-token",
- }),
- );
- }),
- ),
- );
-
- it.effect("preserves encrypted secrets when metadata is rewritten", () =>
- withSavedEnvironments(
- Effect.gen(function* () {
- const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments;
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
- yield* savedEnvironments.setSecret({
- environmentId: savedRegistryRecord.environmentId,
- secret: "bearer-token",
- });
-
- yield* savedEnvironments.setRegistry([savedRegistryRecord]);
-
- assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]);
- assert.deepEqual(
- yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId),
- Option.some("bearer-token"),
- );
- }),
- ),
- );
});
diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts
index 79959bc3c733..f54b464a7dbd 100644
--- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts
+++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts
@@ -1,13 +1,11 @@
import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
import { fromLenientJson } from "@t3tools/shared/schemaJson";
import * as Context from "effect/Context";
-import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Encoding from "effect/Encoding";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
-import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
@@ -65,37 +63,12 @@ const SavedEnvironmentRegistryDocumentJson = fromLenientJson(
const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect(
SavedEnvironmentRegistryDocumentJson,
);
-const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect(
- SavedEnvironmentRegistryDocumentJson,
-);
-
-const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([
- "create-temporary-file-name",
- "encode-registry",
- "create-directory",
- "write-temporary-file",
- "replace-registry-file",
-]);
const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([
"check-encryption-availability",
- "encrypt-secret",
"decrypt-secret",
]);
-export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedError()(
- "DesktopSavedEnvironmentsWriteError",
- {
- operation: DesktopSavedEnvironmentsWriteOperation,
- path: Schema.String,
- cause: Schema.Defect(),
- },
-) {
- override get message(): string {
- return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`;
- }
-}
-
export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError()(
"DesktopSavedEnvironmentsReadError",
{
@@ -152,19 +125,12 @@ export type DesktopSavedEnvironmentsReadRegistryError =
| DesktopSavedEnvironmentsReadError
| DesktopSavedEnvironmentsDocumentDecodeError;
-export type DesktopSavedEnvironmentsMutationError =
- | DesktopSavedEnvironmentsReadRegistryError
- | DesktopSavedEnvironmentsWriteError;
-
export type DesktopSavedEnvironmentsGetSecretError =
| DesktopSavedEnvironmentsReadRegistryError
| DesktopSavedEnvironmentSecretDecodeError
| DesktopSavedEnvironmentSecretProtectionError;
-export type DesktopSavedEnvironmentsSetSecretError =
- | DesktopSavedEnvironmentsMutationError
- | DesktopSavedEnvironmentSecretProtectionError;
-
+/** Reads the previous registry for connection-catalog migration. */
export class DesktopSavedEnvironments extends Context.Service<
DesktopSavedEnvironments,
{
@@ -172,22 +138,10 @@ export class DesktopSavedEnvironments extends Context.Service<
readonly PersistedSavedEnvironmentRecord[],
DesktopSavedEnvironmentsReadRegistryError
>;
- readonly setRegistry: (
- records: readonly PersistedSavedEnvironmentRecord[],
- ) => Effect.Effect;
- readonly removeEnvironment: (
- environmentId: string,
- ) => Effect.Effect;
+
readonly getSecret: (
environmentId: string,
) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>;
- readonly setSecret: (input: {
- readonly environmentId: string;
- readonly secret: string;
- }) => Effect.Effect;
- readonly removeSecret: (
- environmentId: string,
- ) => Effect.Effect;
}
>()("@t3tools/desktop/settings/DesktopSavedEnvironments") {}
@@ -209,28 +163,6 @@ function toPersistedSavedEnvironmentRecord(
};
}
-function toSavedEnvironmentStorageRecord(
- record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord,
- encryptedBearerToken: Option.Option,
-): PersistedSavedEnvironmentStorageRecord {
- const nextRecord = {
- environmentId: record.environmentId,
- label: record.label,
- httpBaseUrl: record.httpBaseUrl,
- wsBaseUrl: record.wsBaseUrl,
- createdAt: record.createdAt,
- lastConnectedAt: record.lastConnectedAt,
- };
- const metadata = {
- ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
- ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
- };
- return Option.match(encryptedBearerToken, {
- onNone: () => ({ ...nextRecord, ...metadata }),
- onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }),
- });
-}
-
function normalizeSavedEnvironmentRegistryDocument(
document: SavedEnvironmentRegistryStorageDocument,
): SavedEnvironmentRegistryDocument {
@@ -272,80 +204,6 @@ function readRegistryDocument(
);
}
-const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")(
- function* (input: {
- readonly fileSystem: FileSystem.FileSystem;
- readonly path: Path.Path;
- readonly registryPath: string;
- readonly document: SavedEnvironmentRegistryDocument;
- readonly suffix: string;
- }): Effect.fn.Return {
- const directory = input.path.dirname(input.registryPath);
- const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`;
- const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe(
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentsWriteError({
- operation: "encode-registry",
- path: input.registryPath,
- cause,
- }),
- ),
- );
- yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentsWriteError({
- operation: "create-directory",
- path: directory,
- cause,
- }),
- ),
- );
- yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentsWriteError({
- operation: "write-temporary-file",
- path: tempPath,
- cause,
- }),
- ),
- );
- yield* input.fileSystem.rename(tempPath, input.registryPath).pipe(
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentsWriteError({
- operation: "replace-registry-file",
- path: input.registryPath,
- cause,
- }),
- ),
- );
- },
-);
-
-function preserveExistingSecrets(
- currentDocument: SavedEnvironmentRegistryDocument,
- records: readonly PersistedSavedEnvironmentRecord[],
-): SavedEnvironmentRegistryDocument {
- const encryptedBearerTokenById = new Map(
- currentDocument.records.flatMap((record) =>
- record.encryptedBearerToken
- ? [[record.environmentId, record.encryptedBearerToken] as const]
- : [],
- ),
- );
-
- return {
- version: currentDocument.version,
- records: records.map((record) => {
- const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
- return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken));
- }),
- };
-}
-
function decodeSecretBytes(
environmentId: string,
registryPath: string,
@@ -368,31 +226,8 @@ function decodeSecretBytes(
export const make = Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
- const crypto = yield* Crypto.Crypto;
- const writeDocument = (document: SavedEnvironmentRegistryDocument) =>
- crypto.randomUUIDv4.pipe(
- Effect.map((uuid) => uuid.replace(/-/g, "")),
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentsWriteError({
- operation: "create-temporary-file-name",
- path: environment.savedEnvironmentRegistryPath,
- cause,
- }),
- ),
- Effect.flatMap((suffix) =>
- writeRegistryDocument({
- fileSystem,
- path,
- registryPath: environment.savedEnvironmentRegistryPath,
- document,
- suffix,
- }),
- ),
- );
+ const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
return DesktopSavedEnvironments.of({
getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe(
@@ -401,30 +236,7 @@ export const make = Effect.gen(function* () {
),
Effect.withSpan("desktop.savedEnvironments.getRegistry"),
),
- setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) {
- const currentDocument = yield* readRegistryDocument(
- fileSystem,
- environment.savedEnvironmentRegistryPath,
- );
- yield* writeDocument(preserveExistingSecrets(currentDocument, records));
- }),
- removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")(
- function* (environmentId) {
- yield* Effect.annotateCurrentSpan({ environmentId });
- const document = yield* readRegistryDocument(
- fileSystem,
- environment.savedEnvironmentRegistryPath,
- );
- if (!document.records.some((record) => record.environmentId === environmentId)) {
- return;
- }
- yield* writeDocument({
- version: document.version,
- records: document.records.filter((record) => record.environmentId !== environmentId),
- });
- },
- ),
getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) {
yield* Effect.annotateCurrentSpan({ environmentId });
const document = yield* readRegistryDocument(
@@ -472,85 +284,6 @@ export const make = Effect.gen(function* () {
),
);
}),
- setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) {
- const { environmentId, secret } = input;
- yield* Effect.annotateCurrentSpan({ environmentId });
- const document = yield* readRegistryDocument(
- fileSystem,
- environment.savedEnvironmentRegistryPath,
- );
-
- const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe(
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentSecretProtectionError({
- operation: "check-encryption-availability",
- environmentId,
- registryPath: environment.savedEnvironmentRegistryPath,
- cause,
- }),
- ),
- );
- if (!encryptionAvailable) {
- return false;
- }
-
- const encryptedBearerToken = Encoding.encodeBase64(
- yield* safeStorage.encryptString(secret).pipe(
- Effect.mapError(
- (cause) =>
- new DesktopSavedEnvironmentSecretProtectionError({
- operation: "encrypt-secret",
- environmentId,
- registryPath: environment.savedEnvironmentRegistryPath,
- cause,
- }),
- ),
- ),
- );
- let found = false;
- const nextDocument: SavedEnvironmentRegistryDocument = {
- version: document.version,
- records: document.records.map((record) => {
- if (record.environmentId !== environmentId) {
- return record;
- }
-
- found = true;
- return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken));
- }),
- };
-
- if (found) {
- yield* writeDocument(nextDocument);
- }
- return found;
- }),
- removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) {
- yield* Effect.annotateCurrentSpan({ environmentId });
- const document = yield* readRegistryDocument(
- fileSystem,
- environment.savedEnvironmentRegistryPath,
- );
- if (
- !document.records.some(
- (record) =>
- record.environmentId === environmentId && record.encryptedBearerToken !== undefined,
- )
- ) {
- return;
- }
-
- yield* writeDocument({
- version: document.version,
- records: document.records.map((record) => {
- if (record.environmentId !== environmentId) {
- return record;
- }
- return toPersistedSavedEnvironmentRecord(record);
- }),
- });
- }),
});
});
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
index 5a76402b1d34..6a369a4598a2 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts
@@ -397,16 +397,6 @@ describe("DesktopShellEnvironment", () => {
}),
);
- it("resolves dbus runtime dir candidates with existence checks", () => {
- const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({
- env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" },
- uid: 1000,
- exists: (path) => path === "/run/user/1000/bus",
- });
-
- assert.equal(busPath, "unix:path=/run/user/1000/bus");
- });
-
it.effect("logs command failures with safe probe context and the exact cause", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/bash",
diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts
index 5c6b67fd58c0..704b061db363 100644
--- a/apps/desktop/src/shell/DesktopShellEnvironment.ts
+++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts
@@ -122,30 +122,6 @@ const linuxRuntimeDirCandidates = (
return candidates.filter((candidate) => candidate.length > 0);
};
-function resolveDefaultLinuxDbusSessionBusPath(input: {
- readonly env: NodeJS.ProcessEnv;
- readonly uid: number | undefined;
- readonly exists?: (path: string) => boolean;
-}): string | null {
- for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) {
- const busPath = `${runtimeDir}/bus`;
- if (input.exists === undefined || input.exists(busPath)) {
- return busPath;
- }
- }
-
- return null;
-}
-
-export function resolveDefaultLinuxDbusSessionBusAddress(input: {
- readonly env: NodeJS.ProcessEnv;
- readonly exists: (path: string) => boolean;
- readonly uid: number | undefined;
-}): string | null {
- const busPath = resolveDefaultLinuxDbusSessionBusPath(input);
- return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null;
-}
-
const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
const normalized = entry.trim().replace(/^"+|"+$/g, "");
return platform === "win32" ? normalized.toLowerCase() : normalized;
diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts
index b0094f6867bc..b5e724084a21 100644
--- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts
+++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts
@@ -45,10 +45,6 @@ export type DesktopSshEnvironmentOperationError =
export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError;
-export type DesktopSshEnvironmentError =
- | DesktopSshEnvironmentDiscoverError
- | DesktopSshEnvironmentOperationError;
-
export class DesktopSshEnvironment extends Context.Service<
DesktopSshEnvironment,
{
diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts
index 120199da6486..5c0bf0998f40 100644
--- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts
+++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts
@@ -164,10 +164,6 @@ export type DesktopSshPasswordPromptResolveError =
| DesktopSshPromptInvalidRequestIdError
| DesktopSshPromptExpiredError;
-export type DesktopSshPasswordPromptError =
- | DesktopSshPasswordPromptRequestError
- | DesktopSshPasswordPromptResolveError;
-
export const DesktopSshPasswordPromptCancellation = Schema.Union([
DesktopSshPromptCancelledError,
DesktopSshPromptWindowClosedError,
diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
index d705252ef588..cef90b29874b 100644
--- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
+++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts
@@ -39,7 +39,6 @@ function makeElectronAppLayer(
onMetricsRead();
return metrics;
}),
- isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
@@ -172,7 +171,7 @@ describe("DesktopTelemetryPublisher", () => {
const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild);
yield* Effect.yieldNow;
- yield* publisher.handleControl({
+ yield* publisher.handleControlForSource("primary-backend", {
version: 1,
type: "setDiagnosticsDemand",
enabled: true,
@@ -188,7 +187,7 @@ describe("DesktopTelemetryPublisher", () => {
type: "setDiagnosticsDemand",
enabled: true,
});
- yield* publisher.handleControl({
+ yield* publisher.handleControlForSource("primary-backend", {
version: 1,
type: "setDiagnosticsDemand",
enabled: false,
@@ -308,7 +307,7 @@ describe("DesktopTelemetryPublisher", () => {
Effect.forkChild,
);
yield* Effect.yieldNow;
- yield* publisher.handleControl({
+ yield* publisher.handleControlForSource("primary-backend", {
version: 1,
type: "setHostPowerIntervals",
activeIntervalMs: 7_000,
@@ -369,7 +368,7 @@ describe("DesktopTelemetryPublisher", () => {
Effect.forkChild,
);
yield* Effect.yieldNow;
- yield* publisher.handleControl({
+ yield* publisher.handleControlForSource("primary-backend", {
version: 1,
type: "setDiagnosticsDemand",
enabled: true,
diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts
index 8fd478c821e7..18ea0a8380a3 100644
--- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts
+++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts
@@ -63,7 +63,6 @@ export class DesktopTelemetryPublisher extends Context.Service<
readonly latest: Effect.Effect>;
readonly changes: Stream.Stream;
readonly encoded: Stream.Stream;
- readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect;
readonly handleControlForSource: (
sourceId: string,
message: DesktopTelemetryControlMessage,
@@ -365,8 +364,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
: Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid),
),
);
- const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) =>
- handleControlForSource("legacy", message);
const snapshots = Stream.unwrap(
Effect.gen(function* () {
@@ -415,7 +412,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () {
latest: Ref.get(latest),
changes: Stream.fromPubSub(changes),
encoded,
- handleControl,
handleControlForSource,
removeControlSource,
publishUpdateReport,
diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts
index 0f4cb970da2c..1198429a1700 100644
--- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts
+++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts
@@ -57,7 +57,6 @@ function runRemoteUpdatesTest(
latest: Effect.succeedNone,
changes: Stream.empty,
encoded: Stream.empty,
- handleControl: () => Effect.void,
handleControlForSource: () => Effect.void,
removeControlSource: () => Effect.void,
publishUpdateReport: (report) =>
diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
index 4b06f5ee510e..5f5cbaa1d7fc 100644
--- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts
+++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
@@ -41,7 +41,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
setAboutPanelOptions: () => Effect.void,
setAppUserModelId: () => Effect.void,
getAppMetrics: Effect.succeed([]),
- isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
setDockIcon: () => Effect.void,
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
index 9b36d60f94ec..74111988f150 100644
--- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -2001,19 +2001,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
)
}
- private func fileStatusText(_ changeType: String?) -> String {
- switch changeType {
- case "new":
- return "A"
- case "deleted":
- return "D"
- case "renamed":
- return "R"
- default:
- return ""
- }
- }
-
private func fileStatusColor(_ changeType: String?) -> UIColor {
switch changeType {
case "new":
@@ -2027,13 +2014,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
}
}
- private func drawStatusPill(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
- let path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2)
- color.withAlphaComponent(0.12).setFill()
- path.fill()
- drawCenteredText(text, rect: rect, color: color, font: font)
- }
-
private func drawFileIcon(rect: CGRect, changeType: String?) {
let color = fileStatusColor(changeType)
let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
index feadf6c81893..8fa346c5be45 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
@@ -4,20 +4,13 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { EnvironmentId } from "@t3tools/contracts";
import { RelayMobileClientId } from "@t3tools/contracts/relay";
-import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
+import { ManagedRelay } from "@t3tools/client-runtime/relay";
import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
import { HttpClient } from "effect/unstable/http";
-import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
+
import { MobileStorage } from "../../persistence/mobile-storage";
-import {
- linkEnvironmentToCloud,
- linkEnvironmentToCloudWithPreference,
- connectCloudEnvironment,
- listCloudEnvironments,
- listCloudEnvironmentsWithStatus,
- refreshCloudEnvironmentConnection,
-} from "./linkEnvironment";
+import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment";
vi.mock("expo-constants", () => ({
default: {
@@ -56,8 +49,6 @@ vi.mock("expo-secure-store", () => ({
setItemAsync: vi.fn(),
}));
-const loadPreferences = vi.fn(() => Effect.succeed({}));
-
const savedConnection = {
environmentId: EnvironmentId.make("env-1"),
environmentLabel: "Desktop",
@@ -68,8 +59,6 @@ const savedConnection = {
bearerToken: "local-bearer",
};
-const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test";
-
const createProofMock = vi.fn(
(input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
Effect.succeed(`dpop:${input.method}:${input.url}`),
@@ -86,14 +75,6 @@ function cloudClientLayer() {
const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
return Layer.mergeAll(
httpClientLayer,
- Layer.succeed(
- MobilePreferencesStore,
- MobilePreferencesStore.of({
- load: loadPreferences(),
- savePatch: (patch) => Effect.succeed(patch),
- update: () => Effect.succeed({}),
- }),
- ),
Layer.succeed(
MobileStorage,
MobileStorage.of({
@@ -123,7 +104,6 @@ const withCloudServices = (
| HttpClient.HttpClient
| ManagedRelay.ManagedRelayClient
| ManagedRelay.ManagedRelayDpopSigner
- | MobilePreferencesStore
| MobileStorage
>,
) => effect.pipe(Effect.provide(cloudClientLayer()));
@@ -163,439 +143,12 @@ function requestBodyText(body: BodyInit | null | undefined): string {
return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
}
-function validDpopAccessTokenResponse(scope = "environment:status environment:connect") {
- return {
- access_token: "relay-dpop-token",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- token_type: "DPoP",
- expires_in: 300,
- scope,
- };
-}
-
-function listedEnvironment(environmentId: string) {
- return {
- environmentId: EnvironmentId.make(environmentId),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: `https://${environmentId}.example.test/`,
- wsBaseUrl: `wss://${environmentId}.example.test/ws`,
- providerKind: "cloudflare_tunnel" as const,
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- };
-}
-
describe("mobile cloud link environment client", () => {
beforeEach(() => {
vi.restoreAllMocks();
createProofMock.mockClear();
- loadPreferences.mockClear();
});
- it.effect("decodes relay environment list responses before returning records", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn(() =>
- Promise.resolve(
- Response.json({
- environments: [
- {
- environmentId: "env-1",
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- ],
- }),
- ),
- ),
- );
-
- const records = yield* withCloudServices(
- listCloudEnvironments({ clerkToken: "clerk-token" }),
- );
- expect(records).toEqual([
- {
- environmentId: "env-1",
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- ]);
- }),
- );
-
- it.effect("rejects malformed relay environment list responses", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn(() =>
- Promise.resolve(
- Response.json({
- environments: [
- {
- environmentId: "env-1",
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- ],
- }),
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- listCloudEnvironments({ clerkToken: "clerk-token" }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "https://relay.example.test/v1/environments failed",
- });
- }),
- );
-
- it.effect("loads signed status for each advertised cloud environment", () =>
- Effect.gen(function* () {
- const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
- if (String(url) === "https://relay.example.test/v1/environments") {
- return Promise.resolve(
- Response.json({
- environments: [
- {
- environmentId: "env-1",
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- ],
- }),
- );
- }
- if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
- return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
- }
- expect(String(url)).toBe("https://relay.example.test/v1/environments/env-1/status");
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- status: "online",
- checkedAt: "2026-05-25T00:01:00.000Z",
- descriptor: {
- environmentId: "env-1",
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- },
- }),
- );
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const records = yield* withCloudServices(
- listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
- );
- expect(records).toMatchObject([
- {
- environment: {
- environmentId: "env-1",
- label: "Desktop",
- },
- status: {
- environmentId: "env-1",
- status: "online",
- checkedAt: "2026-05-25T00:01:00.000Z",
- },
- statusError: null,
- },
- ]);
- expect(String(fetchMock.mock.calls[2]?.[0])).toBe(
- "https://relay.example.test/v1/environments/env-1/status",
- );
- expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST");
- const statusHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers);
- expect(statusHeaders.get("authorization")).toBe("DPoP relay-dpop-token");
- expect(statusHeaders.get("dpop")).toBe(
- "dpop:POST:https://relay.example.test/v1/environments/env-1/status",
- );
- expect(createProofMock).toHaveBeenCalledWith({
- method: "POST",
- url: "https://relay.example.test/v1/environments/env-1/status",
- accessToken: "relay-dpop-token",
- });
- }),
- );
-
- it.effect("reuses one valid DPoP access token while probing multiple environment statuses", () =>
- Effect.gen(function* () {
- const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
- if (String(url).endsWith("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/v1/environments")) {
- return Promise.resolve(
- Response.json({
- environments: [listedEnvironment("env-1"), listedEnvironment("env-2")],
- }),
- );
- }
- if (String(url).endsWith("/v1/client/dpop-token")) {
- return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
- }
- const environmentId = String(url).includes("/env-1/") ? "env-1" : "env-2";
- return Promise.resolve(
- Response.json({
- environmentId,
- endpoint: listedEnvironment(environmentId).endpoint,
- status: "online",
- checkedAt: "2026-05-25T00:01:00.000Z",
- descriptor: {
- environmentId,
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- },
- }),
- );
- });
- vi.stubGlobal("fetch", fetchMock);
-
- yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken }));
-
- expect(
- fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")),
- ).toHaveLength(1);
- }),
- );
-
- it.effect("reuses the status-and-connect token when connecting from the cloud list", () =>
- Effect.gen(function* () {
- const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
- if (String(url).endsWith("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/v1/environments")) {
- return Promise.resolve(
- Response.json({
- environments: [listedEnvironment("env-1")],
- }),
- );
- }
- if (String(url).endsWith("/v1/client/dpop-token")) {
- return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
- }
- if (String(url).endsWith("/status")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: listedEnvironment("env-1").endpoint,
- status: "online",
- checkedAt: "2026-05-25T00:01:00.000Z",
- descriptor: {
- environmentId: "env-1",
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- },
- }),
- );
- }
- if (String(url).endsWith("/connect")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: listedEnvironment("env-1").endpoint,
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- );
- }
- if (String(url).endsWith("/.well-known/t3/environment")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- }),
- );
- }
- return Promise.resolve(
- Response.json({
- access_token: "environment-dpop-token",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- token_type: "DPoP",
- expires_in: 3600,
- scope: "orchestration:read orchestration:operate terminal:operate review:write",
- }),
- );
- });
- vi.stubGlobal("fetch", fetchMock);
-
- yield* withCloudServices(
- Effect.gen(function* () {
- const records = yield* listCloudEnvironmentsWithStatus({
- clerkToken: stableClerkToken,
- });
- yield* connectCloudEnvironment({
- clerkToken: stableClerkToken,
- environment: records[0]!.environment,
- });
- }),
- );
-
- expect(
- fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")),
- ).toHaveLength(1);
- const exchangeRequest = fetchMock.mock.calls.find(([url]) =>
- String(url).endsWith("/v1/client/dpop-token"),
- )?.[1];
- expect(new URLSearchParams(requestBodyText(exchangeRequest?.body)).get("scope")).toBe(
- "environment:status environment:connect",
- );
- const environmentTokenRequest = fetchMock.mock.calls.find(([url]) =>
- String(url).endsWith("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/oauth/token"),
- )?.[1];
- const environmentTokenBody = new URLSearchParams(
- requestBodyText(environmentTokenRequest?.body),
- );
- expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile");
- expect(environmentTokenBody.get("client_device_type")).toBe("mobile");
- expect(environmentTokenBody.get("client_os")).toBe("iOS");
- }),
- );
-
- it.effect("keeps advertised environments visible when status probing fails", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) => {
- if (String(url) === "https://relay.example.test/v1/environments") {
- return Promise.resolve(
- Response.json({
- environments: [
- {
- environmentId: "env-1",
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- ],
- }),
- );
- }
- if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
- return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
- }
- return Promise.resolve(Response.json({ error: "offline" }, { status: 503 }));
- }),
- );
-
- const records = yield* withCloudServices(
- listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
- );
- expect(records).toMatchObject([
- {
- environment: {
- environmentId: "env-1",
- label: "Desktop",
- },
- status: null,
- statusError: "https://relay.example.test/v1/environments/env-1/status failed",
- },
- ]);
- }),
- );
-
- it.effect("rejects status responses for a different advertised environment", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) => {
- if (String(url) === "https://relay.example.test/v1/environments") {
- return Promise.resolve(
- Response.json({
- environments: [
- {
- environmentId: "env-1",
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- ],
- }),
- );
- }
- if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
- return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
- }
- return Promise.resolve(
- Response.json({
- environmentId: "env-other",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- status: "online",
- checkedAt: "2026-05-25T00:01:00.000Z",
- descriptor: {
- environmentId: "env-other",
- label: "Other Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- },
- }),
- );
- }),
- );
-
- const records = yield* withCloudServices(
- listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
- );
- expect(records).toMatchObject([
- {
- environment: {
- environmentId: "env-1",
- label: "Desktop",
- },
- status: null,
- statusError: "Relay returned status for a different environment.",
- },
- ]);
- }),
- );
-
it.effect(
"rejects relay link credentials for a different environment before persisting relay config",
() =>
@@ -612,9 +165,10 @@ describe("mobile cloud link environment client", () => {
vi.stubGlobal("fetch", fetchMock);
const error = yield* withCloudServices(
- linkEnvironmentToCloud({
+ linkEnvironmentToCloudWithPreference({
clerkToken: "clerk-token",
connection: savedConnection,
+ liveActivitiesEnabled: true,
}),
).pipe(Effect.flip);
expect(error).toMatchObject({
@@ -644,9 +198,10 @@ describe("mobile cloud link environment client", () => {
vi.stubGlobal("fetch", fetchMock);
const error = yield* withCloudServices(
- linkEnvironmentToCloud({
+ linkEnvironmentToCloudWithPreference({
clerkToken: "clerk-token",
connection: savedConnection,
+ liveActivitiesEnabled: true,
}),
).pipe(Effect.flip);
expect(error._tag).toBe("CloudEnvironmentLinkError");
@@ -681,9 +236,10 @@ describe("mobile cloud link environment client", () => {
vi.stubGlobal("fetch", fetchMock);
const error = yield* withCloudServices(
- linkEnvironmentToCloud({
+ linkEnvironmentToCloudWithPreference({
clerkToken: "clerk-token",
connection: savedConnection,
+ liveActivitiesEnabled: true,
}),
).pipe(Effect.flip);
expect(error).toMatchObject({
@@ -718,9 +274,10 @@ describe("mobile cloud link environment client", () => {
vi.stubGlobal("fetch", fetchMock);
const error = yield* withCloudServices(
- linkEnvironmentToCloud({
+ linkEnvironmentToCloudWithPreference({
clerkToken: "clerk-token",
connection: savedConnection,
+ liveActivitiesEnabled: true,
}),
).pipe(Effect.flip);
expect(error).toMatchObject({
@@ -733,7 +290,6 @@ describe("mobile cloud link environment client", () => {
it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
Effect.gen(function* () {
- loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false }));
const bodies: Array = [];
const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
if (init?.body) {
@@ -756,9 +312,10 @@ describe("mobile cloud link environment client", () => {
vi.stubGlobal("fetch", fetchMock);
yield* withCloudServices(
- linkEnvironmentToCloud({
+ linkEnvironmentToCloudWithPreference({
clerkToken: "clerk-token",
connection: savedConnection,
+ liveActivitiesEnabled: false,
}),
);
@@ -786,9 +343,8 @@ describe("mobile cloud link environment client", () => {
}),
);
- it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () =>
+ it.effect("enables Live Activities for both the link challenge and registration", () =>
Effect.gen(function* () {
- loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read"));
const bodies: Array> = [];
const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
if (init?.body) {
@@ -824,429 +380,4 @@ describe("mobile cloud link environment client", () => {
]);
}),
);
-
- it.effect(
- "does not persist cloud connect bootstrap credentials in saved connection records",
- () =>
- Effect.gen(function* () {
- let connectRequestBody = "";
- const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
- if (String(url).endsWith("/v1/client/dpop-token")) {
- return Promise.resolve(
- Response.json(validDpopAccessTokenResponse("environment:connect")),
- );
- }
- if (String(url).endsWith("/.well-known/t3/environment")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- }),
- );
- }
- if (String(url).endsWith("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/oauth/token")) {
- return Promise.resolve(
- Response.json({
- access_token: "environment-dpop-token",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- token_type: "DPoP",
- expires_in: 3600,
- scope: "orchestration:read orchestration:operate terminal:operate review:write",
- }),
- );
- }
- connectRequestBody = requestBodyText(init?.body);
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- );
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const connection = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- );
-
- expect(connection.pairingUrl).toBe("https://desktop.example.test/");
- expect(connection.pairingUrl).not.toContain("one-time-cloud-credential");
- expect(connection.bearerToken).toBeNull();
- expect(connection.authenticationMethod).toBe("dpop");
- expect(connection.dpopAccessToken).toBe("environment-dpop-token");
- expect(connection.relayManaged).toBe(true);
- // @effect-diagnostics-next-line preferSchemaOverJson:off
- expect(JSON.parse(connectRequestBody)).toMatchObject({
- deviceId: "device-1",
- clientKeyThumbprint: "client-proof-key-thumbprint",
- });
- expect(createProofMock).toHaveBeenCalledWith({
- method: "POST",
- url: "https://relay.example.test/v1/environments/env-1/connect",
- accessToken: "relay-dpop-token",
- });
- expect(createProofMock).toHaveBeenCalledWith({
- method: "POST",
- url: "https://desktop.example.test/oauth/token",
- });
- }),
- );
-
- it.effect("refreshes a saved environment against a rotated managed endpoint", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) => {
- if (String(url).endsWith("/v1/client/dpop-token")) {
- return Promise.resolve(
- Response.json(validDpopAccessTokenResponse("environment:connect")),
- );
- }
- if (String(url).endsWith("/.well-known/t3/environment")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- label: "Rotated Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- }),
- );
- }
- if (String(url).endsWith("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/oauth/token")) {
- return Promise.resolve(
- Response.json({
- access_token: "fresh-environment-dpop-token",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- token_type: "DPoP",
- expires_in: 3600,
- scope: "orchestration:read orchestration:operate terminal:operate review:write",
- }),
- );
- }
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://rotated-desktop.example.test/",
- wsBaseUrl: "wss://rotated-desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- credential: "rotated-one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- );
- }),
- );
-
- const connection = yield* withCloudServices(
- refreshCloudEnvironmentConnection({
- clerkToken: "clerk-token",
- connection: {
- environmentId: EnvironmentId.make("env-1"),
- environmentLabel: "Desktop",
- pairingUrl: "https://desktop.example.test/",
- displayUrl: "https://desktop.example.test/",
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- bearerToken: null,
- authenticationMethod: "dpop",
- relayManaged: true,
- },
- }),
- );
-
- expect(connection).toMatchObject({
- environmentId: "env-1",
- environmentLabel: "Rotated Desktop",
- displayUrl: "https://rotated-desktop.example.test/",
- httpBaseUrl: "https://rotated-desktop.example.test/",
- wsBaseUrl: "wss://rotated-desktop.example.test/ws",
- dpopAccessToken: "fresh-environment-dpop-token",
- });
- }),
- );
-
- it.effect("rejects relay connect responses for a different environment", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) =>
- Promise.resolve(
- String(url).endsWith("/v1/client/dpop-token")
- ? Response.json(validDpopAccessTokenResponse("environment:connect"))
- : Response.json({
- environmentId: "env-other",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Relay returned credentials for a different environment.",
- });
- }),
- );
-
- it.effect("preserves relay DPoP auth failures while connecting environments", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) =>
- Promise.resolve(
- String(url).endsWith("/v1/client/dpop-token")
- ? Response.json(validDpopAccessTokenResponse("environment:connect"))
- : Response.json(
- {
- _tag: "RelayAuthInvalidError",
- code: "auth_invalid",
- reason: "invalid_dpop",
- traceId: "trace-connect",
- },
- { status: 401 },
- ),
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
- traceId: "trace-connect",
- });
- }),
- );
-
- it.effect(
- "presents clock skew as one possible cause when an older environment rejects DPoP",
- () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) => {
- const value = String(url);
- if (value.endsWith("/v1/client/dpop-token")) {
- return Promise.resolve(
- Response.json(validDpopAccessTokenResponse("environment:connect")),
- );
- }
- if (value.endsWith("/v1/environments/env-1/connect")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- );
- }
- if (value.endsWith("/.well-known/t3/environment")) {
- return Promise.resolve(
- Response.json({
- environmentId: "env-1",
- label: "Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- }),
- );
- }
- return Promise.resolve(
- Response.json(
- {
- _tag: "EnvironmentAuthInvalidError",
- code: "auth_invalid",
- reason: "invalid_credential",
- traceId: "trace-environment",
- },
- { status: 401 },
- ),
- );
- }),
- );
-
- const error = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- ).pipe(Effect.flip);
-
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`,
- traceId: "trace-environment",
- });
- }),
- );
-
- it.effect("rejects relay connect responses for a different endpoint", () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) =>
- Promise.resolve(
- String(url).endsWith("/v1/client/dpop-token")
- ? Response.json(validDpopAccessTokenResponse("environment:connect"))
- : Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://other-desktop.example.test/",
- wsBaseUrl: "wss://other-desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Relay returned credentials for a different endpoint.",
- });
- }),
- );
-
- it.effect(
- "rejects managed endpoints whose descriptor does not match the selected environment",
- () =>
- Effect.gen(function* () {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string | URL) =>
- Promise.resolve(
- String(url).endsWith("/v1/client/dpop-token")
- ? Response.json(validDpopAccessTokenResponse("environment:connect"))
- : String(url).endsWith("/.well-known/t3/environment")
- ? Response.json({
- environmentId: "env-other",
- label: "Other Desktop",
- platform: { os: "darwin", arch: "arm64" },
- serverVersion: "0.0.0-test",
- capabilities: { repositoryIdentity: true },
- })
- : Response.json({
- environmentId: "env-1",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- credential: "one-time-cloud-credential",
- expiresAt: "2026-05-25T00:05:00.000Z",
- }),
- ),
- ),
- );
-
- const error = yield* withCloudServices(
- connectCloudEnvironment({
- clerkToken: "clerk-token",
- environment: {
- environmentId: EnvironmentId.make("env-1"),
- label: "Desktop",
- endpoint: {
- httpBaseUrl: "https://desktop.example.test/",
- wsBaseUrl: "wss://desktop.example.test/ws",
- providerKind: "cloudflare_tunnel",
- },
- linkedAt: "2026-05-25T00:00:00.000Z",
- },
- }),
- ).pipe(Effect.flip);
- expect(error).toMatchObject({
- _tag: "CloudEnvironmentLinkError",
- message: "Connected endpoint descriptor does not match the selected environment.",
- });
- }),
- );
});
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts
index b8dc8f878e0c..73bd28c550ed 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.ts
@@ -4,45 +4,24 @@ import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import {
EnvironmentCloudEndpointUnavailableError,
- EnvironmentAuthInvalidError,
EnvironmentHttpBadRequestError,
EnvironmentHttpConflictError,
EnvironmentHttpForbiddenError,
EnvironmentHttpInternalServerError,
EnvironmentHttpUnauthorizedError,
} from "@t3tools/contracts";
-import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
import {
- type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType,
type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,
- RelayEnvironmentConnectScope,
- RelayEnvironmentStatusScope,
- type RelayDpopAccessTokenScope,
- type RelayClientEnvironmentRecord,
- type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
type RelayManagedEndpointProviderKind,
} from "@t3tools/contracts/relay";
-import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
-import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
-import {
- dpopFailureMessage,
- ManagedRelay,
- relayProtectedErrorMessage,
-} from "@t3tools/client-runtime/relay";
+import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay";
import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
-import { authClientMetadata } from "../../lib/authClientMetadata";
import type { SavedRemoteConnection } from "../../lib/connection";
-import * as MobilePreferences from "../../persistence/mobile-preferences";
import * as MobileStorage from "../../persistence/mobile-storage";
import { resolveCloudPublicConfig } from "./publicConfig";
-const RELAY_STATUS_AND_CONNECT_SCOPES = [
- RelayEnvironmentStatusScope,
- RelayEnvironmentConnectScope,
-] satisfies ReadonlyArray;
-
function readRelayUrl(): string | null {
return resolveCloudPublicConfig().relay.url;
}
@@ -53,12 +32,6 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen
readonly traceId?: string;
}> {}
-export interface CloudEnvironmentRecordWithStatus {
- readonly environment: RelayClientEnvironmentRecord;
- readonly status: RelayEnvironmentStatusResponseType | null;
- readonly statusError: string | null;
-}
-
const isEnvironmentCloudApiError = Schema.is(
Schema.Union([
EnvironmentHttpBadRequestError,
@@ -69,24 +42,19 @@ const isEnvironmentCloudApiError = Schema.is(
EnvironmentCloudEndpointUnavailableError,
]),
);
-const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
const MANAGED_ENDPOINT_PROVIDER_KIND =
"cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
-function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {
+function cloudEnvironmentLinkError(message: string) {
return (cause: unknown) => {
const environmentError = findEnvironmentCloudApiError(cause);
const traceId = findErrorTraceId(cause);
- const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null;
const detail = environmentError
? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
: withDevCause(message, cause);
return new CloudEnvironmentLinkError({
- message:
- dpopAuthError?.reason === "invalid_credential"
- ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
- : detail,
+ message: detail,
cause,
...(traceId === null ? {} : { traceId }),
});
@@ -143,16 +111,6 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
}
-function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
- if (isEnvironmentAuthInvalidError(cause)) {
- return cause;
- }
- if (typeof cause !== "object" || cause === null) {
- return null;
- }
- return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null;
-}
-
function requireRelayUrl(): Effect.Effect {
const relayUrl = readRelayUrl();
return relayUrl
@@ -186,54 +144,6 @@ function ensureLinkedEnvironmentMatches(input: {
return Effect.void;
}
-function endpointMatches(
- left: RelayClientEnvironmentRecord["endpoint"],
- right: RelayClientEnvironmentRecord["endpoint"],
-): boolean {
- return (
- left.httpBaseUrl === right.httpBaseUrl &&
- left.wsBaseUrl === right.wsBaseUrl &&
- left.providerKind === right.providerKind
- );
-}
-
-function ensureStatusMatchesEnvironment(input: {
- readonly environment: RelayClientEnvironmentRecord;
- readonly status: RelayEnvironmentStatusResponseType;
-}): Effect.Effect {
- if (input.status.environmentId !== input.environment.environmentId) {
- return new CloudEnvironmentLinkError({
- message: "Relay returned status for a different environment.",
- });
- }
- if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) {
- return new CloudEnvironmentLinkError({
- message: "Relay returned status for a different endpoint.",
- });
- }
- if (
- input.status.descriptor &&
- input.status.descriptor.environmentId !== input.environment.environmentId
- ) {
- return new CloudEnvironmentLinkError({
- message: "Relay returned status descriptor for a different environment.",
- });
- }
- return Effect.void;
-}
-
-function ensureConnectEndpointMatchesEnvironment(input: {
- readonly environment: RelayClientEnvironmentRecord;
- readonly connect: RelayEnvironmentConnectResponseType;
-}): Effect.Effect {
- if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) {
- return new CloudEnvironmentLinkError({
- message: "Relay returned credentials for a different endpoint.",
- });
- }
- return Effect.void;
-}
-
interface LinkEnvironmentToCloudInput {
readonly connection: SavedRemoteConnection;
readonly clerkToken: string;
@@ -328,250 +238,3 @@ export function linkEnvironmentToCloudWithPreference(
);
});
}
-
-export function linkEnvironmentToCloud(
- input: LinkEnvironmentToCloudInput,
-): Effect.Effect<
- void,
- CloudEnvironmentLinkError,
- LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore
-> {
- return MobilePreferences.MobilePreferencesStore.pipe(
- Effect.flatMap((preferencesStore) => preferencesStore.load),
- Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")),
- Effect.flatMap((preferences) =>
- linkEnvironmentToCloudWithPreference({
- ...input,
- liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false,
- }),
- ),
- );
-}
-
-export function listCloudEnvironments(input: {
- readonly clerkToken: string;
-}): Effect.Effect<
- ReadonlyArray,
- CloudEnvironmentLinkError,
- ManagedRelay.ManagedRelayClient
-> {
- return Effect.gen(function* () {
- const relayUrl = yield* requireRelayUrl();
- const relayClient = yield* ManagedRelay.ManagedRelayClient;
-
- return yield* relayClient
- .listEnvironments({
- clerkToken: input.clerkToken,
- })
- .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`)));
- });
-}
-
-export function getCloudEnvironmentStatus(input: {
- readonly clerkToken: string;
- readonly environment: RelayClientEnvironmentRecord;
- readonly relayScopes?: ReadonlyArray;
-}): Effect.Effect<
- RelayEnvironmentStatusResponseType,
- CloudEnvironmentLinkError,
- ManagedRelay.ManagedRelayClient
-> {
- return Effect.gen(function* () {
- const relayUrl = yield* requireRelayUrl();
- const relayClient = yield* ManagedRelay.ManagedRelayClient;
- const status = yield* relayClient
- .getEnvironmentStatus({
- clerkToken: input.clerkToken,
- scopes: input.relayScopes ?? [RelayEnvironmentStatusScope],
- environmentId: input.environment.environmentId,
- })
- .pipe(
- Effect.mapError(
- decodedRelayClientError(
- `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`,
- ),
- ),
- );
- yield* ensureStatusMatchesEnvironment({ environment: input.environment, status });
- return status;
- });
-}
-
-export function loadCloudEnvironmentStatuses(input: {
- readonly clerkToken: string;
- readonly environments: ReadonlyArray;
-}): Effect.Effect<
- ReadonlyArray,
- CloudEnvironmentLinkError,
- ManagedRelay.ManagedRelayClient
-> {
- return Effect.forEach(
- input.environments,
- (environment) =>
- getCloudEnvironmentStatus({
- clerkToken: input.clerkToken,
- environment,
- relayScopes: RELAY_STATUS_AND_CONNECT_SCOPES,
- }).pipe(
- Effect.match({
- onFailure: (error) => ({
- environment,
- status: null,
- statusError: error.message,
- }),
- onSuccess: (status) => ({
- environment,
- status,
- statusError: null,
- }),
- }),
- ),
- { concurrency: "unbounded" },
- );
-}
-
-export function listCloudEnvironmentsWithStatus(input: {
- readonly clerkToken: string;
-}): Effect.Effect<
- ReadonlyArray,
- CloudEnvironmentLinkError,
- ManagedRelay.ManagedRelayClient
-> {
- return Effect.gen(function* () {
- const environments = yield* listCloudEnvironments(input);
- return yield* loadCloudEnvironmentStatuses({
- clerkToken: input.clerkToken,
- environments,
- });
- });
-}
-
-const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(
- function* () {
- const storage = yield* MobileStorage.MobileStorage;
- return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
- Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")),
- );
- },
-);
-
-const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")(
- function* (input: {
- readonly clerkToken: string;
- readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
- readonly expectedEnvironment?: RelayClientEnvironmentRecord;
- }) {
- yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId });
- const relayUrl = yield* requireRelayUrl();
- const relayClient = yield* ManagedRelay.ManagedRelayClient;
-
- const deviceId = yield* loadAgentAwarenessDeviceId();
- const connect = yield* relayClient
- .connectEnvironment({
- clerkToken: input.clerkToken,
- scopes: [RelayEnvironmentConnectScope],
- environmentId: input.environmentId,
- deviceId,
- })
- .pipe(
- Effect.mapError(
- decodedRelayClientError(
- `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`,
- ),
- ),
- );
- if (connect.environmentId !== input.environmentId) {
- return yield* new CloudEnvironmentLinkError({
- message: "Relay returned credentials for a different environment.",
- });
- }
- if (input.expectedEnvironment) {
- yield* ensureConnectEndpointMatchesEnvironment({
- environment: input.expectedEnvironment,
- connect,
- });
- }
-
- const descriptor = yield* fetchRemoteEnvironmentDescriptor({
- httpBaseUrl: connect.endpoint.httpBaseUrl,
- }).pipe(
- Effect.mapError(
- cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."),
- ),
- );
- if (descriptor.environmentId !== connect.environmentId) {
- return yield* new CloudEnvironmentLinkError({
- message: "Connected endpoint descriptor does not match the selected environment.",
- });
- }
- const signer = yield* ManagedRelay.ManagedRelayDpopSigner;
- const bootstrapDpop = yield* signer
- .createProof({
- method: "POST",
- url: new URL("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/oauth/token", connect.endpoint.httpBaseUrl).toString(),
- })
- .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof.")));
- const bootstrap = yield* exchangeRemoteDpopAccessToken({
- httpBaseUrl: connect.endpoint.httpBaseUrl,
- credential: connect.credential,
- dpopProof: bootstrapDpop,
- clientMetadata: authClientMetadata(),
- }).pipe(
- Effect.mapError(
- cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", {
- dpop: true,
- }),
- ),
- );
- const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
- pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString();
-
- return {
- environmentId: descriptor.environmentId,
- environmentLabel: descriptor.label,
- pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(),
- displayUrl: connect.endpoint.httpBaseUrl,
- httpBaseUrl: connect.endpoint.httpBaseUrl,
- wsBaseUrl: connect.endpoint.wsBaseUrl,
- bearerToken: null,
- authenticationMethod: "dpop",
- dpopAccessToken: bootstrap.access_token,
- relayManaged: true,
- } satisfies SavedRemoteConnection;
- },
-);
-
-export function connectCloudEnvironment(input: {
- readonly clerkToken: string;
- readonly environment: RelayClientEnvironmentRecord;
-}): Effect.Effect<
- SavedRemoteConnection,
- CloudEnvironmentLinkError,
- | HttpClient.HttpClient
- | ManagedRelay.ManagedRelayClient
- | ManagedRelay.ManagedRelayDpopSigner
- | MobileStorage.MobileStorage
-> {
- return connectRelayManagedEnvironment({
- clerkToken: input.clerkToken,
- environmentId: input.environment.environmentId,
- expectedEnvironment: input.environment,
- });
-}
-
-export function refreshCloudEnvironmentConnection(input: {
- readonly clerkToken: string;
- readonly connection: SavedRemoteConnection;
-}): Effect.Effect<
- SavedRemoteConnection,
- CloudEnvironmentLinkError,
- | HttpClient.HttpClient
- | ManagedRelay.ManagedRelayClient
- | ManagedRelay.ManagedRelayDpopSigner
- | MobileStorage.MobileStorage
-> {
- return connectRelayManagedEnvironment({
- clerkToken: input.clerkToken,
- environmentId: input.connection.environmentId,
- });
-}
diff --git a/apps/mobile/src/features/review/reviewPerf.ts b/apps/mobile/src/features/review/reviewPerf.ts
index 2ae1095b1c6a..2613ff0c3dbb 100644
--- a/apps/mobile/src/features/review/reviewPerf.ts
+++ b/apps/mobile/src/features/review/reviewPerf.ts
@@ -42,33 +42,6 @@ export function measureReviewWork(name: string, callback: () => T): T {
}
}
-export async function measureReviewAsyncWork(
- name: string,
- callback: () => Promise,
-): Promise {
- if (!isReviewPerfEnabled()) {
- return callback();
- }
-
- const perf = getPerformance();
- const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`;
- const startMark = `${marker}.start`;
- const endMark = `${marker}.end`;
- const startedAt = perf?.now?.() ?? Date.now();
-
- perf?.mark?.(startMark);
- try {
- return await callback();
- } finally {
- const durationMs = (perf?.now?.() ?? Date.now()) - startedAt;
- perf?.mark?.(endMark);
- perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark);
- perf?.clearMarks?.(startMark);
- perf?.clearMarks?.(endMark);
- console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) });
- }
-}
-
export function markReviewEvent(name: string, details?: Record): void {
if (!isReviewPerfEnabled()) {
return;
diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts
index 52e0b83be34d..3050f1f67ee8 100644
--- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts
+++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts
@@ -15,14 +15,12 @@ import * as Schema from "effect/Schema";
import {
resolveReviewHighlighterEngine,
resolveReviewHighlighterEnginePreference,
- type ReviewHighlighterEngine,
} from "./reviewHighlighterEngine";
import { createIncrementalSnippet } from "./incrementalSnippet";
import type { ReviewRenderableLineRow } from "./reviewModel";
import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
export type ReviewDiffTheme = "light" | "dark";
-export type { ReviewHighlighterEngine };
export class ReviewHighlighterEngineInitializationError extends Schema.TaggedError()(
"ReviewHighlighterEngineInitializationError",
@@ -181,7 +179,6 @@ const languageAliases: Record = {
txt: "text",
};
let highlighterPromise: Promise | null = null;
-let activeHighlighterEnginePromise: Promise | null = null;
type LoadedLanguageModule = {
default: Parameters[0];
@@ -254,10 +251,7 @@ async function getHighlighter(): Promise {
engine: nativeEngineModule.createNativeEngine(),
});
logReviewHighlighterDiagnostic("using native engine");
- return {
- highlighter,
- engine: "native" as const,
- };
+ return highlighter;
}
} catch (error) {
nativeInitializationError = new ReviewHighlighterEngineInitializationError({
@@ -308,55 +302,18 @@ async function getHighlighter(): Promise {
logReviewHighlighterDiagnostic("using javascript engine", {
resolvedEngine: engine,
});
- return {
- highlighter,
- engine,
- };
+ return highlighter;
})();
- highlighterPromise = configuredHighlighterPromise
- .then((result) => result.highlighter)
- .catch((error) => {
- highlighterPromise = null;
- activeHighlighterEnginePromise = null;
- throw error;
- });
- activeHighlighterEnginePromise = configuredHighlighterPromise
- .then((result) => result.engine)
- .catch((error) => {
- activeHighlighterEnginePromise = null;
- throw error;
- });
+ highlighterPromise = configuredHighlighterPromise.catch((error) => {
+ highlighterPromise = null;
+ throw error;
+ });
}
return highlighterPromise;
}
-export async function getActiveReviewHighlighterEngine(): Promise {
- await getHighlighter();
- return activeHighlighterEnginePromise ?? Promise.resolve("javascript");
-}
-
-export async function prepareReviewHighlighter(): Promise {
- await getHighlighter();
-}
-
-export async function prepareReviewHighlighterLanguages(
- languages: ReadonlyArray,
-): Promise {
- const highlighter = await getHighlighter();
- await Promise.all(
- languages.map(async (language) => {
- const candidate = resolveLanguageAlias(language);
- if (candidate === "text" || !(candidate in languageImports)) {
- return;
- }
-
- await loadSingleLanguage(highlighter, candidate);
- }),
- );
-}
-
function resolveLanguageAlias(language: string): string {
const normalized = language.toLowerCase();
return languageAliases[normalized] ?? normalized;
diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index ace2b0fc43bf..0df54be2f701 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -28,11 +28,9 @@ import * as CheckpointStore from "../src/checkpointing/CheckpointStore.ts";
import { TextGeneration } from "../src/textGeneration/TextGeneration.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
-import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
import * as ProviderSessionRuntime from "../src/persistence/ProviderSessionRuntime.ts";
import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
-import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts";
import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
@@ -189,7 +187,6 @@ export interface OrchestrationIntegrationHarness {
readonly snapshotQuery: ProjectionSnapshotQuery["Service"];
readonly providerService: ProviderService["Service"];
readonly checkpointStore: CheckpointStore.CheckpointStore["Service"];
- readonly checkpointRepository: ProjectionCheckpointRepository["Service"];
readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];
readonly waitForThread: (
threadId: string,
@@ -313,7 +310,6 @@ export const makeOrchestrationIntegrationHarness = (
const runtimeServicesLayer = Layer.mergeAll(
projectionSnapshotQueryLayer,
orchestrationLayer.pipe(Layer.provide(projectionSnapshotQueryLayer)),
- ProjectionCheckpointRepositoryLive,
ProjectionPendingApprovalRepositoryLive,
checkpointStoreLayer,
providerLayer,
@@ -463,10 +459,6 @@ export const makeOrchestrationIntegrationHarness = (
const checkpointStore = yield* tryRuntimePromise("load CheckpointStore service", () =>
runtime.runPromise(Effect.service(CheckpointStore.CheckpointStore)),
).pipe(Effect.orDie);
- const checkpointRepository = yield* tryRuntimePromise(
- "load ProjectionCheckpointRepository service",
- () => runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
- ).pipe(Effect.orDie);
const pendingApprovalRepository = yield* tryRuntimePromise(
"load ProjectionPendingApprovalRepository service",
() => runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)),
@@ -612,7 +604,6 @@ export const makeOrchestrationIntegrationHarness = (
snapshotQuery,
providerService,
checkpointStore,
- checkpointRepository,
pendingApprovalRepository,
waitForThread,
waitForDomainEvent,
diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts
index abe00f16bb81..68b566e2ee4a 100644
--- a/apps/server/integration/orchestrationEngine.integration.test.ts
+++ b/apps/server/integration/orchestrationEngine.integration.test.ts
@@ -20,7 +20,6 @@ import {
import { assert, it } from "@effect/vitest";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
-import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
@@ -247,14 +246,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
);
assert.equal(thread.checkpoints[0]?.status, "ready");
assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);
-
- const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
- threadId: THREAD_ID,
- });
- assert.equal(checkpointRows.length, 1);
- assert.equal(checkpointRows[0]?.checkpointTurnCount, 1);
- assert.equal(checkpointRows[0]?.status, "ready");
- assert.deepEqual(checkpointRows[0]?.files, []);
+ assert.deepEqual(thread.checkpoints[0]?.files, []);
const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);
const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);
@@ -498,11 +490,8 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
true,
);
- const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
- threadId: THREAD_ID,
- });
assert.deepEqual(
- checkpointRows.map((row) => row.checkpointTurnCount),
+ secondTurnThread.checkpoints.map((row) => row.checkpointTurnCount),
[1, 2],
);
@@ -688,14 +677,7 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
assert.equal(thread.session?.status, "error");
assert.equal(thread.checkpoints[0]?.status, "error");
- const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({
- threadId: THREAD_ID,
- checkpointTurnCount: 1,
- });
- assert.equal(Option.isSome(checkpointRow), true);
- if (Option.isSome(checkpointRow)) {
- assert.equal(checkpointRow.value.status, "error");
- }
+ assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1);
assert.equal(
gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)),
true,
@@ -878,11 +860,6 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
false,
);
assert.deepEqual(harness.adapterHarness!.getRollbackCalls(THREAD_ID), [1]);
-
- const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
- threadId: THREAD_ID,
- });
- assert.equal(checkpointRows.length, 1);
}),
),
);
diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts
index dccb8b4be0f7..5e6376072ce1 100644
--- a/apps/server/src/orchestration/Errors.ts
+++ b/apps/server/src/orchestration/Errors.ts
@@ -4,30 +4,6 @@ import * as Schema from "effect/Schema";
import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
-export class OrchestrationCommandJsonParseError extends Schema.TaggedError()(
- "OrchestrationCommandJsonParseError",
- {
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
- },
-) {
- override get message(): string {
- return `Invalid orchestration command JSON: ${this.detail}`;
- }
-}
-
-export class OrchestrationCommandDecodeError extends Schema.TaggedError()(
- "OrchestrationCommandDecodeError",
- {
- issue: Schema.String,
- cause: Schema.optional(Schema.Defect()),
- },
-) {
- override get message(): string {
- return `Invalid orchestration command payload: ${this.issue}`;
- }
-}
-
export class OrchestrationCommandInvariantError extends Schema.TaggedError()(
"OrchestrationCommandInvariantError",
{
@@ -100,31 +76,12 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedError()(
- "OrchestrationListenerCallbackError",
- {
- listener: Schema.Literals(["read-model", "domain-event"]),
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
- },
-) {
- override get message(): string {
- return `Orchestration ${this.listener} listener failed: ${this.detail}`;
- }
-}
-
export type OrchestrationDispatchError =
| ProjectionRepositoryError
| OrchestrationCommandRejection
| OrchestrationCommandIdConflictError
| OrchestrationCommandPreviouslyRejectedError
- | OrchestrationProjectorDecodeError
- | OrchestrationListenerCallbackError;
-
-export type OrchestrationEngineError =
- | OrchestrationDispatchError
- | OrchestrationCommandJsonParseError
- | OrchestrationCommandDecodeError;
+ | OrchestrationProjectorDecodeError;
export function toProjectorDecodeError(eventType: string) {
return (error: Schema.SchemaError): OrchestrationProjectorDecodeError =>
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
index 03bf17329286..1e7058742e25 100644
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -8,6 +8,7 @@ import {
MessageId,
NonNegativeInt,
OrchestrationCheckpointFile,
+ OrchestrationCheckpointStatus,
OrchestrationProposedPlanId,
OrchestrationReadModel,
OrchestrationThreadSearchSource,
@@ -52,7 +53,6 @@ import {
toPersistenceSqlError,
type ProjectionRepositoryError,
} from "../../persistence/Errors.ts";
-import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts";
import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
@@ -151,11 +151,16 @@ const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
title: Schema.String,
session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
});
-const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
- Struct.assign({
- files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
- }),
-);
+const ProjectionCheckpointDbRowSchema = Schema.Struct({
+ threadId: ThreadId,
+ turnId: TurnId,
+ checkpointTurnCount: NonNegativeInt,
+ checkpointRef: CheckpointRef,
+ status: OrchestrationCheckpointStatus,
+ files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
+ assistantMessageId: Schema.NullOr(MessageId),
+ completedAt: IsoDateTime,
+});
const ProjectionLatestTurnDbRowSchema = Schema.Struct({
threadId: ProjectionThread.fields.threadId,
turnId: TurnId,
diff --git a/apps/server/src/persistence/Errors.test.ts b/apps/server/src/persistence/Errors.test.ts
index dfa35020df33..5b9d37b3a655 100644
--- a/apps/server/src/persistence/Errors.test.ts
+++ b/apps/server/src/persistence/Errors.test.ts
@@ -42,7 +42,7 @@ it.effect("names a real SQLite condition without copying query data", () =>
assert.equal(error.cause, cause);
assert.notInclude(error.message, payload);
assert.notInclude(error.message, "private_column");
- }).pipe(Effect.provide(NodeSqliteClient.layerMemory())),
+ }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
it("reads the condition through a wrapping driver error", () => {
diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts
index 4625b368d8f2..1e3c9027879d 100644
--- a/apps/server/src/persistence/Errors.ts
+++ b/apps/server/src/persistence/Errors.ts
@@ -124,42 +124,8 @@ export function toPersistenceDecodeError(operation: string) {
export const isPersistenceError = (u: unknown) =>
isPersistenceSqlError(u) || isPersistenceDecodeError(u);
-// ===============================
-// Provider Session Repository Errors
-// ===============================
-
-export class ProviderSessionRepositoryValidationError extends Schema.TaggedError()(
- "ProviderSessionRepositoryValidationError",
- {
- operation: Schema.String,
- issue: Schema.String,
- cause: Schema.optional(Schema.Defect()),
- },
-) {
- override get message(): string {
- return `Provider session repository validation failed in ${this.operation}: ${this.issue}`;
- }
-}
-
-export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedError()(
- "ProviderSessionRepositoryPersistenceError",
- {
- operation: Schema.String,
- detail: Schema.String,
- cause: Schema.optional(Schema.Defect()),
- },
-) {
- override get message(): string {
- return `Provider session repository persistence error in ${this.operation}: ${this.detail}`;
- }
-}
-
export type OrchestrationEventStoreError = PersistenceSqlError | PersistenceDecodeError;
-export type ProviderSessionRepositoryError =
- | ProviderSessionRepositoryValidationError
- | ProviderSessionRepositoryPersistenceError;
-
export type OrchestrationCommandReceiptRepositoryError =
| PersistenceSqlError
| PersistenceDecodeError;
diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts
deleted file mode 100644
index cfa5658d0e9c..000000000000
--- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-import { OrchestrationCheckpointFile } from "@t3tools/contracts";
-import * as SqlClient from "effect/unstable/sql/SqlClient";
-import * as SqlSchema from "effect/unstable/sql/SqlSchema";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-import * as Option from "effect/Option";
-import * as Schema from "effect/Schema";
-import * as Struct from "effect/Struct";
-
-import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
-import {
- DeleteByThreadIdInput,
- GetByThreadAndTurnCountInput,
- ListByThreadIdInput,
- ProjectionCheckpoint,
- ProjectionCheckpointRepository,
- type ProjectionCheckpointRepositoryShape,
-} from "../Services/ProjectionCheckpoints.ts";
-
-const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
- Struct.assign({
- files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
- }),
-);
-
-function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
- return (cause: unknown) =>
- Schema.isSchemaError(cause)
- ? toPersistenceDecodeError(decodeOperation)(cause)
- : toPersistenceSqlError(sqlOperation)(cause);
-}
-
-const makeProjectionCheckpointRepository = Effect.gen(function* () {
- const sql = yield* SqlClient.SqlClient;
-
- const clearCheckpointConflict = SqlSchema.void({
- Request: GetByThreadAndTurnCountInput,
- execute: ({ threadId, checkpointTurnCount }) =>
- sql`
- UPDATE projection_turns
- SET
- checkpoint_turn_count = NULL,
- checkpoint_ref = NULL,
- checkpoint_status = NULL,
- checkpoint_files_json = '[]'
- WHERE thread_id = ${threadId}
- AND checkpoint_turn_count = ${checkpointTurnCount}
- `,
- });
-
- const upsertProjectionCheckpointRow = SqlSchema.void({
- Request: ProjectionCheckpointDbRowSchema,
- execute: (row) =>
- sql`
- INSERT INTO projection_turns (
- thread_id,
- turn_id,
- pending_message_id,
- assistant_message_id,
- state,
- requested_at,
- started_at,
- completed_at,
- checkpoint_turn_count,
- checkpoint_ref,
- checkpoint_status,
- checkpoint_files_json
- )
- VALUES (
- ${row.threadId},
- ${row.turnId},
- NULL,
- ${row.assistantMessageId},
- ${row.status === "error" ? "error" : "completed"},
- ${row.completedAt},
- ${row.completedAt},
- ${row.completedAt},
- ${row.checkpointTurnCount},
- ${row.checkpointRef},
- ${row.status},
- ${row.files}
- )
- ON CONFLICT (thread_id, turn_id)
- DO UPDATE SET
- assistant_message_id = excluded.assistant_message_id,
- state = excluded.state,
- completed_at = excluded.completed_at,
- checkpoint_turn_count = excluded.checkpoint_turn_count,
- checkpoint_ref = excluded.checkpoint_ref,
- checkpoint_status = excluded.checkpoint_status,
- checkpoint_files_json = excluded.checkpoint_files_json
- `,
- });
-
- const listProjectionCheckpointRows = SqlSchema.findAll({
- Request: ListByThreadIdInput,
- Result: ProjectionCheckpointDbRowSchema,
- execute: ({ threadId }) =>
- sql`
- SELECT
- thread_id AS "threadId",
- turn_id AS "turnId",
- checkpoint_turn_count AS "checkpointTurnCount",
- checkpoint_ref AS "checkpointRef",
- checkpoint_status AS "status",
- checkpoint_files_json AS "files",
- assistant_message_id AS "assistantMessageId",
- completed_at AS "completedAt"
- FROM projection_turns
- WHERE thread_id = ${threadId}
- AND checkpoint_turn_count IS NOT NULL
- ORDER BY checkpoint_turn_count ASC
- `,
- });
-
- const getProjectionCheckpointRow = SqlSchema.findOneOption({
- Request: GetByThreadAndTurnCountInput,
- Result: ProjectionCheckpointDbRowSchema,
- execute: ({ threadId, checkpointTurnCount }) =>
- sql`
- SELECT
- thread_id AS "threadId",
- turn_id AS "turnId",
- checkpoint_turn_count AS "checkpointTurnCount",
- checkpoint_ref AS "checkpointRef",
- checkpoint_status AS "status",
- checkpoint_files_json AS "files",
- assistant_message_id AS "assistantMessageId",
- completed_at AS "completedAt"
- FROM projection_turns
- WHERE thread_id = ${threadId}
- AND checkpoint_turn_count = ${checkpointTurnCount}
- `,
- });
-
- const deleteProjectionCheckpointRows = SqlSchema.void({
- Request: DeleteByThreadIdInput,
- execute: ({ threadId }) =>
- sql`
- UPDATE projection_turns
- SET
- checkpoint_turn_count = NULL,
- checkpoint_ref = NULL,
- checkpoint_status = NULL,
- checkpoint_files_json = '[]'
- WHERE thread_id = ${threadId}
- AND checkpoint_turn_count IS NOT NULL
- `,
- });
-
- const upsertCheckpointRow = (row: Schema.Schema.Type) =>
- sql.withTransaction(
- clearCheckpointConflict({
- threadId: row.threadId,
- checkpointTurnCount: row.checkpointTurnCount,
- }).pipe(Effect.flatMap(() => upsertProjectionCheckpointRow(row))),
- );
-
- const upsert: ProjectionCheckpointRepositoryShape["upsert"] = (row) =>
- upsertCheckpointRow(row).pipe(
- Effect.mapError(
- toPersistenceSqlOrDecodeError(
- "ProjectionCheckpointRepository.upsert:query",
- "ProjectionCheckpointRepository.upsert:encodeRequest",
- ),
- ),
- );
-
- const listByThreadId: ProjectionCheckpointRepositoryShape["listByThreadId"] = (input) =>
- listProjectionCheckpointRows(input).pipe(
- Effect.mapError(
- toPersistenceSqlOrDecodeError(
- "ProjectionCheckpointRepository.listByThreadId:query",
- "ProjectionCheckpointRepository.listByThreadId:decodeRows",
- ),
- ),
- Effect.map((rows) => rows as ReadonlyArray>),
- );
-
- const getByThreadAndTurnCount: ProjectionCheckpointRepositoryShape["getByThreadAndTurnCount"] = (
- input,
- ) =>
- getProjectionCheckpointRow(input).pipe(
- Effect.mapError(
- toPersistenceSqlOrDecodeError(
- "ProjectionCheckpointRepository.getByThreadAndTurnCount:query",
- "ProjectionCheckpointRepository.getByThreadAndTurnCount:decodeRow",
- ),
- ),
- Effect.flatMap((rowOption) =>
- Option.match(rowOption, {
- onNone: () => Effect.succeed(Option.none()),
- onSome: (row) =>
- Effect.succeed(Option.some(row as Schema.Schema.Type)),
- }),
- ),
- );
-
- const deleteByThreadId: ProjectionCheckpointRepositoryShape["deleteByThreadId"] = (input) =>
- deleteProjectionCheckpointRows(input).pipe(
- Effect.mapError(
- toPersistenceSqlError("ProjectionCheckpointRepository.deleteByThreadId:query"),
- ),
- );
-
- return {
- upsert,
- listByThreadId,
- getByThreadAndTurnCount,
- deleteByThreadId,
- } satisfies ProjectionCheckpointRepositoryShape;
-});
-
-export const ProjectionCheckpointRepositoryLive = Layer.effect(
- ProjectionCheckpointRepository,
- makeProjectionCheckpointRepository,
-);
diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts
index d5631cb0a62c..883198402ee9 100644
--- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts
+++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts
@@ -7,7 +7,6 @@ import * as Schema from "effect/Schema";
import { toPersistenceSqlError } from "../Errors.ts";
import {
GetProjectionPendingApprovalInput,
- DeleteProjectionPendingApprovalInput,
ListProjectionPendingApprovalsInput,
ProjectionPendingApproval,
ProjectionPendingApprovalRepository,
@@ -97,15 +96,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
`,
});
- const deleteProjectionPendingApprovalRow = SqlSchema.void({
- Request: DeleteProjectionPendingApprovalInput,
- execute: ({ requestId }) =>
- sql`
- DELETE FROM projection_pending_approvals
- WHERE request_id = ${requestId}
- `,
- });
-
const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({
Request: ListProjectionPendingApprovalsInput,
execute: ({ threadId }) =>
@@ -143,15 +133,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
),
);
- const deleteByRequestId: ProjectionPendingApprovalRepositoryShape["deleteByRequestId"] = (
- input,
- ) =>
- deleteProjectionPendingApprovalRow(input).pipe(
- Effect.mapError(
- toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByRequestId:query"),
- ),
- );
-
const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) =>
deleteProjectionPendingApprovalRowsByThread(input).pipe(
Effect.mapError(
@@ -164,7 +145,6 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
listByThreadId,
countPendingByThreadId,
getByRequestId,
- deleteByRequestId,
deleteByThreadId,
} satisfies ProjectionPendingApprovalRepositoryShape;
});
diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts
index f34f81294124..c4f07dc4912b 100644
--- a/apps/server/src/persistence/Layers/ProjectionProjects.ts
+++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts
@@ -9,7 +9,6 @@ import * as Struct from "effect/Struct";
import { ModelSelection, ProjectIconOverride, ProjectScript } from "@t3tools/contracts";
import { toPersistenceSqlError } from "../Errors.ts";
import {
- DeleteProjectionProjectInput,
GetProjectionProjectInput,
ProjectionProject,
ProjectionProjectRepository,
@@ -24,7 +23,6 @@ const ProjectionProjectDbRow = ProjectionProject.mapFields(
scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
}),
);
-type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type;
const makeProjectionProjectRepository = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
@@ -100,38 +98,6 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
`,
});
- const listProjectionProjectRows = SqlSchema.findAll({
- Request: Schema.Void,
- Result: ProjectionProjectDbRow,
- execute: () =>
- sql`
- SELECT
- project_id AS "projectId",
- title,
- workspace_root AS "workspaceRoot",
- default_model_selection_json AS "defaultModelSelection",
- default_thread_env_mode AS "defaultThreadEnvMode",
- auto_pull AS "autoPull",
- favicon_path AS "faviconPath",
- project_icon_json AS "projectIcon",
- scripts_json AS "scripts",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- deleted_at AS "deletedAt"
- FROM projection_projects
- ORDER BY created_at ASC, project_id ASC
- `,
- });
-
- const deleteProjectionProjectRow = SqlSchema.void({
- Request: DeleteProjectionProjectInput,
- execute: ({ projectId }) =>
- sql`
- DELETE FROM projection_projects
- WHERE project_id = ${projectId}
- `,
- });
-
const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>
upsertProjectionProjectRow(row).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")),
@@ -143,22 +109,9 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),
);
- const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>
- listProjectionProjectRows().pipe(
- Effect.map((rows) => rows.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))),
- Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")),
- );
-
- const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) =>
- deleteProjectionProjectRow(input).pipe(
- Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.deleteById:query")),
- );
-
return {
upsert,
getById,
- listAll,
- deleteById,
} satisfies ProjectionProjectRepositoryShape;
});
diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts
index b7b2fdefba2f..4be65bd5650c 100644
--- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts
+++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts
@@ -518,9 +518,6 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest);
assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest);
- const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId });
- assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest);
-
const row = Option.getOrNull(persisted);
if (row === null) return yield* Effect.die("Expected linked thread row to exist.");
yield* threads.upsert({ ...row, linkedPullRequest: null });
diff --git a/apps/server/src/persistence/Layers/ProjectionState.ts b/apps/server/src/persistence/Layers/ProjectionState.ts
index a6fb7f3be4dd..383ab7d9fee8 100644
--- a/apps/server/src/persistence/Layers/ProjectionState.ts
+++ b/apps/server/src/persistence/Layers/ProjectionState.ts
@@ -1,4 +1,3 @@
-import { NonNegativeInt } from "@t3tools/contracts";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
import * as Effect from "effect/Effect";
@@ -14,10 +13,6 @@ import {
ProjectionState,
} from "../Services/ProjectionState.ts";
-const MinLastAppliedSequenceRowSchema = Schema.Struct({
- minLastAppliedSequence: Schema.NullOr(NonNegativeInt),
-});
-
const makeProjectionStateRepository = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
@@ -90,17 +85,6 @@ const makeProjectionStateRepository = Effect.gen(function* () {
`,
});
- const readMinLastAppliedSequence = SqlSchema.findOne({
- Request: Schema.Void,
- Result: MinLastAppliedSequenceRowSchema,
- execute: () =>
- sql`
- SELECT
- MIN(last_applied_sequence) AS "minLastAppliedSequence"
- FROM projection_state
- `,
- });
-
const upsert: ProjectionStateRepositoryShape["upsert"] = (row) =>
upsertProjectionStateRow(row).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.upsert:query")),
@@ -121,20 +105,11 @@ const makeProjectionStateRepository = Effect.gen(function* () {
Effect.mapError(toPersistenceSqlError("ProjectionStateRepository.listAll:query")),
);
- const minLastAppliedSequence: ProjectionStateRepositoryShape["minLastAppliedSequence"] = () =>
- readMinLastAppliedSequence(undefined).pipe(
- Effect.mapError(
- toPersistenceSqlError("ProjectionStateRepository.minLastAppliedSequence:query"),
- ),
- Effect.map((row) => row.minLastAppliedSequence),
- );
-
return {
upsert,
upsertMany,
getByProjector,
listAll,
- minLastAppliedSequence,
} satisfies ProjectionStateRepositoryShape;
});
diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts
index 4feaf3a185b9..af36578f286e 100644
--- a/apps/server/src/persistence/Layers/ProjectionThreads.ts
+++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts
@@ -7,9 +7,7 @@ import * as Struct from "effect/Struct";
import { toPersistenceSqlError } from "../Errors.ts";
import {
- DeleteProjectionThreadInput,
GetProjectionThreadInput,
- ListProjectionThreadsByProjectInput,
ProjectionThread,
ProjectionThreadRepository,
type ProjectionThreadRepositoryShape,
@@ -24,7 +22,6 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields(
branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)),
}),
);
-type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
const makeProjectionThreadRepository = Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
@@ -172,57 +169,6 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
`,
});
- const listProjectionThreadRows = SqlSchema.findAll({
- Request: ListProjectionThreadsByProjectInput,
- Result: ProjectionThreadDbRow,
- execute: ({ projectId }) =>
- sql`
- SELECT
- thread_id AS "threadId",
- project_id AS "projectId",
- title,
- title_state_json AS "titleState",
- model_selection_json AS "modelSelection",
- runtime_mode AS "runtimeMode",
- interaction_mode AS "interactionMode",
- branch,
- worktree_path AS "worktreePath",
- linked_pull_request_json AS "linkedPullRequest",
- branch_pull_request_json AS "branchPullRequest",
- latest_turn_id AS "latestTurnId",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- archived_at AS "archivedAt",
- settled_override AS "settledOverride",
- settled_at AS "settledAt",
- unsettled_at AS "unsettledAt",
- snoozed_until AS "snoozedUntil",
- snoozed_at AS "snoozedAt",
- pinned_at AS "pinnedAt",
- pin_order_key AS "pinOrderKey",
- active_order_key AS "activeOrderKey",
- title_regeneration_request_id AS "titleRegenerationRequestId",
- title_regeneration_started_at AS "titleRegenerationStartedAt",
- latest_user_message_at AS "latestUserMessageAt",
- pending_approval_count AS "pendingApprovalCount",
- pending_user_input_count AS "pendingUserInputCount",
- has_actionable_proposed_plan AS "hasActionableProposedPlan",
- deleted_at AS "deletedAt"
- FROM projection_threads
- WHERE project_id = ${projectId}
- ORDER BY created_at ASC, thread_id ASC
- `,
- });
-
- const deleteProjectionThreadRow = SqlSchema.void({
- Request: DeleteProjectionThreadInput,
- execute: ({ threadId }) =>
- sql`
- DELETE FROM projection_threads
- WHERE thread_id = ${threadId}
- `,
- });
-
const upsert: ProjectionThreadRepositoryShape["upsert"] = (row) =>
upsertProjectionThreadRow(row).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.upsert:query")),
@@ -233,21 +179,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")),
);
- const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) =>
- listProjectionThreadRows(input).pipe(
- Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")),
- );
-
- const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) =>
- deleteProjectionThreadRow(input).pipe(
- Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.deleteById:query")),
- );
-
return {
upsert,
getById,
- listByProjectId,
- deleteById,
} satisfies ProjectionThreadRepositoryShape;
});
diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts
index fff5a738622d..ca32b1bddd41 100644
--- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts
+++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("016_CanonicalizeModelSelections", (it) => {
it.effect(
diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts
index 040a9fa47b92..248c74b43b34 100644
--- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts
+++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("019_ProjectionSnapshotLookupIndexes", (it) => {
it.effect("creates indexes for targeted projection lookup filters", () =>
diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts
index 49585fb36f37..73c579a13366 100644
--- a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts
+++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("024_BackfillProjectionThreadShellSummary", (it) => {
it.effect("backfills thread shell summary fields and clears stale projected approvals", () =>
diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts
index efdf88bf6c3b..7e65f4c04f72 100644
--- a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts
+++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("025_CleanupInvalidProjectionPendingApprovals", (it) => {
it.effect("removes pending-approval rows that do not come from approval requests", () =>
diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts
index 558183e216cc..5dd053cfe77c 100644
--- a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts
+++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("026_CanonicalizeModelSelectionOptions", (it) => {
it.effect("converts legacy object-shape options into array-shape on projections and events", () =>
diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts
index b5e4f5cf3a22..aa99f0c862a2 100644
--- a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts
+++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("027_028_ProviderInstanceIdColumns", (it) => {
it.effect("continues when provider_session_runtime was partially migrated", () =>
diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts
index 7078450c96ec..cd246d16ee87 100644
--- a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts
+++ b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("029_ProjectionThreadDetailOrderingIndexes", (it) => {
it.effect("creates indexes matching thread detail ordering queries", () =>
diff --git a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts
index 63eba11aac71..60b0079d79f9 100644
--- a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts
+++ b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("031_AuthAuthorizationScopes", (it) => {
it.effect("invalidates role-based auth records and installs scoped auth tables", () =>
diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts
index 0e7f54812c21..cf0ecff3253a 100644
--- a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts
+++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("035_ProjectionThreadTitleRegeneration", (it) => {
it.effect("adds pending title regeneration columns", () =>
diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts
index 427288125007..524831e891ab 100644
--- a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts
+++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("040_ProjectionProjectFaviconPath", (it) => {
it.effect("adds the nullable favicon path to project projections", () =>
diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts
index b19f1ce7cf20..4f106de404a0 100644
--- a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts
+++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("041_AuthSessionClientConnection", (it) => {
it.effect("adds nullable client surface and app version columns to auth sessions", () =>
diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts
index 02e7f2f6c59f..a4ed1653a962 100644
--- a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts
+++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("042_ProjectionThreadLinkedPullRequest", (it) => {
it.effect("adds the linked pull request column", () =>
diff --git a/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts
index f562fa655a11..de7c6f01f659 100644
--- a/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts
+++ b/apps/server/src/persistence/Migrations/046_RepairAutomaticSettlementTimestamps.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
const MODEL_SELECTION = '{"instanceId":"codex","model":"gpt-5.6-sol"}';
diff --git a/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts
index da3f7cd366d9..c601bb0077fe 100644
--- a/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts
+++ b/apps/server/src/persistence/Migrations/047_ProjectionProjectIcon.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("047_ProjectionProjectIcon", (it) => {
it.effect("adds the nullable project icon JSON to project projections", () =>
diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts
index 138d25754d7e..938ab25d0f8a 100644
--- a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts
+++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts
@@ -6,13 +6,15 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import { runMigrations } from "../Migrations.ts";
import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts";
-it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => {
- it.effect("migrates old threads without changing their timestamps or assigning an order", () =>
- Effect.gen(function* () {
- const sql = yield* SqlClient.SqlClient;
- yield* runMigrations({ toMigrationInclusive: 48 });
- const now = "2026-01-01T00:00:00.000Z";
- yield* sql`
+it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))(
+ "049_ProjectionThreadsActiveOrderKey",
+ (it) => {
+ it.effect("migrates old threads without changing their timestamps or assigning an order", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* runMigrations({ toMigrationInclusive: 48 });
+ const now = "2026-01-01T00:00:00.000Z";
+ yield* sql`
INSERT INTO projection_threads (
thread_id, project_id, title, model_selection_json, runtime_mode,
created_at, updated_at
@@ -21,24 +23,25 @@ it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey",
'{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now}
)
`;
- yield* runMigrations({ toMigrationInclusive: 49 });
- const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`
+ yield* runMigrations({ toMigrationInclusive: 49 });
+ const migrated = yield* sql<{ readonly activeOrderKey: string | null }>`
SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1'
`;
- assert.deepEqual(migrated, [{ activeOrderKey: null }]);
- // Recovery may run the same migration against a database that already
- // has the column, including a placement written after the upgrade.
- yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`;
- yield* migrateActiveOrderKey;
- const rows = yield* sql<{
- readonly activeOrderKey: string | null;
- readonly createdAt: string;
- readonly updatedAt: string;
- }>`
+ assert.deepEqual(migrated, [{ activeOrderKey: null }]);
+ // Recovery may run the same migration against a database that already
+ // has the column, including a placement written after the upgrade.
+ yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`;
+ yield* migrateActiveOrderKey;
+ const rows = yield* sql<{
+ readonly activeOrderKey: string | null;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ }>`
SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt"
FROM projection_threads WHERE thread_id = 'thread-1'
`;
- assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);
- }),
- );
-});
+ assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]);
+ }),
+ );
+ },
+);
diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts
index 61d9f0618e9c..7f3bc58d1cc1 100644
--- a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts
+++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts
@@ -9,7 +9,7 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
interface PullRequestRow {
readonly threadId: string;
@@ -156,26 +156,29 @@ layer("050_ProjectionThreadPullRequests", (it) => {
);
});
-it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => {
- it.effect("keeps legacy Azure repositories distinct across organizations", () =>
- Effect.gen(function* () {
- const sql = yield* SqlClient.SqlClient;
- yield* runMigrations({ toMigrationInclusive: 49 });
- for (const organization of ["org-a", "org-b"]) {
- yield* sql`
+it.layer(Layer.fresh(NodeSqliteClient.layer({ filename: ":memory:" })))(
+ "050 Azure legacy links",
+ (it) => {
+ it.effect("keeps legacy Azure repositories distinct across organizations", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* runMigrations({ toMigrationInclusive: 49 });
+ for (const organization of ["org-a", "org-b"]) {
+ yield* sql`
INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at)
VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}',
${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })},
'2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')
`;
- }
- yield* runMigrations({ toMigrationInclusive: 50 });
- const rows =
- yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
- assert.deepStrictEqual(rows, [
- { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
- { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
- ]);
- }),
- );
-});
+ }
+ yield* runMigrations({ toMigrationInclusive: 50 });
+ const rows =
+ yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`;
+ assert.deepStrictEqual(rows, [
+ { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 },
+ { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 },
+ ]);
+ }),
+ );
+ },
+);
diff --git a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts
index c3cdfa1b9dcd..a0e59217cd30 100644
--- a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts
+++ b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageContext.test.ts
@@ -6,7 +6,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import { runMigrations } from "../Migrations.ts";
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
-const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
+const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layer({ filename: ":memory:" })));
layer("051_ProjectionThreadMessageContext", (it) => {
it.effect("accepts context added by an earlier development migration", () =>
diff --git a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts b/apps/server/src/persistence/Services/ProjectionCheckpoints.ts
deleted file mode 100644
index 7796ebec2a88..000000000000
--- a/apps/server/src/persistence/Services/ProjectionCheckpoints.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * ProjectionCheckpointRepository - Projection repository interface for checkpoints.
- *
- * Owns persistence operations for projected checkpoint summaries in thread
- * timelines.
- *
- * @module ProjectionCheckpointRepository
- */
-import {
- CheckpointRef,
- IsoDateTime,
- MessageId,
- NonNegativeInt,
- OrchestrationCheckpointFile,
- OrchestrationCheckpointStatus,
- ThreadId,
- TurnId,
-} from "@t3tools/contracts";
-import * as Option from "effect/Option";
-import * as Context from "effect/Context";
-import * as Schema from "effect/Schema";
-import type * as Effect from "effect/Effect";
-
-import type { ProjectionRepositoryError } from "../Errors.ts";
-
-export const ProjectionCheckpoint = Schema.Struct({
- threadId: ThreadId,
- turnId: TurnId,
- checkpointTurnCount: NonNegativeInt,
- checkpointRef: CheckpointRef,
- status: OrchestrationCheckpointStatus,
- files: Schema.Array(OrchestrationCheckpointFile),
- assistantMessageId: Schema.NullOr(MessageId),
- completedAt: IsoDateTime,
-});
-export type ProjectionCheckpoint = typeof ProjectionCheckpoint.Type;
-
-export const ListByThreadIdInput = Schema.Struct({
- threadId: ThreadId,
-});
-export type ListByThreadIdInput = typeof ListByThreadIdInput.Type;
-
-export const GetByThreadAndTurnCountInput = Schema.Struct({
- threadId: ThreadId,
- checkpointTurnCount: NonNegativeInt,
-});
-export type GetByThreadAndTurnCountInput = typeof GetByThreadAndTurnCountInput.Type;
-
-export const DeleteByThreadIdInput = Schema.Struct({
- threadId: ThreadId,
-});
-export type DeleteByThreadIdInput = typeof DeleteByThreadIdInput.Type;
-
-/**
- * ProjectionCheckpointRepositoryShape - Service API for projected checkpoints.
- */
-export interface ProjectionCheckpointRepositoryShape {
- /**
- * Insert or replace a projected checkpoint row.
- *
- * Upserts by composite key `(threadId, checkpointTurnCount)`.
- */
- readonly upsert: (row: ProjectionCheckpoint) => Effect.Effect;
-
- /**
- * List projected checkpoints for a thread.
- *
- * Returned in ascending checkpoint turn-count order.
- */
- readonly listByThreadId: (
- input: ListByThreadIdInput,
- ) => Effect.Effect, ProjectionRepositoryError>;
-
- /**
- * Read a projected checkpoint by thread and turn-count key.
- */
- readonly getByThreadAndTurnCount: (
- input: GetByThreadAndTurnCountInput,
- ) => Effect.Effect, ProjectionRepositoryError>;
-
- /**
- * Delete projected checkpoint rows by thread.
- */
- readonly deleteByThreadId: (
- input: DeleteByThreadIdInput,
- ) => Effect.Effect;
-}
-
-/**
- * ProjectionCheckpointRepository - Service tag for checkpoint projection persistence.
- */
-export class ProjectionCheckpointRepository extends Context.Service<
- ProjectionCheckpointRepository,
- ProjectionCheckpointRepositoryShape
->()("t3/persistence/Services/ProjectionCheckpoints/ProjectionCheckpointRepository") {}
diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts
index 43a829de6390..9b9e090af760 100644
--- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts
+++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts
@@ -42,11 +42,6 @@ export const GetProjectionPendingApprovalInput = Schema.Struct({
});
export type GetProjectionPendingApprovalInput = typeof GetProjectionPendingApprovalInput.Type;
-export const DeleteProjectionPendingApprovalInput = Schema.Struct({
- requestId: ApprovalRequestId,
-});
-export type DeleteProjectionPendingApprovalInput = typeof DeleteProjectionPendingApprovalInput.Type;
-
/**
* ProjectionPendingApprovalRepositoryShape - Service API for pending approvals.
*/
@@ -81,13 +76,6 @@ export interface ProjectionPendingApprovalRepositoryShape {
input: GetProjectionPendingApprovalInput,
) => Effect.Effect, ProjectionRepositoryError>;
- /**
- * Delete a pending approval row by request id.
- */
- readonly deleteByRequestId: (
- input: DeleteProjectionPendingApprovalInput,
- ) => Effect.Effect;
-
/**
* Delete every pending approval row for a thread.
*/
diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts
index e5d1f6ba1f1d..3228855fc8ec 100644
--- a/apps/server/src/persistence/Services/ProjectionProjects.ts
+++ b/apps/server/src/persistence/Services/ProjectionProjects.ts
@@ -42,11 +42,6 @@ export const GetProjectionProjectInput = Schema.Struct({
});
export type GetProjectionProjectInput = typeof GetProjectionProjectInput.Type;
-export const DeleteProjectionProjectInput = Schema.Struct({
- projectId: ProjectId,
-});
-export type DeleteProjectionProjectInput = typeof DeleteProjectionProjectInput.Type;
-
/**
* ProjectionProjectRepositoryShape - Service API for projected project records.
*/
@@ -64,23 +59,6 @@ export interface ProjectionProjectRepositoryShape {
readonly getById: (
input: GetProjectionProjectInput,
) => Effect.Effect, ProjectionRepositoryError>;
-
- /**
- * List all projected project rows.
- *
- * Returned in deterministic creation order.
- */
- readonly listAll: () => Effect.Effect<
- ReadonlyArray,
- ProjectionRepositoryError
- >;
-
- /**
- * Soft-delete a projected project row by id.
- */
- readonly deleteById: (
- input: DeleteProjectionProjectInput,
- ) => Effect.Effect;
}
/**
diff --git a/apps/server/src/persistence/Services/ProjectionState.ts b/apps/server/src/persistence/Services/ProjectionState.ts
index 9372acdeeb65..52dbc4cf637c 100644
--- a/apps/server/src/persistence/Services/ProjectionState.ts
+++ b/apps/server/src/persistence/Services/ProjectionState.ts
@@ -53,13 +53,6 @@ export interface ProjectionStateRepositoryShape {
* List all projector cursor rows.
*/
readonly listAll: () => Effect.Effect, ProjectionRepositoryError>;
-
- /**
- * Read the minimum applied sequence across all projectors.
- *
- * Returns `null` when no projector state rows exist.
- */
- readonly minLastAppliedSequence: () => Effect.Effect;
}
/**
diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts
index 2c8186a321c0..895fe596db24 100644
--- a/apps/server/src/persistence/Services/ProjectionThreads.ts
+++ b/apps/server/src/persistence/Services/ProjectionThreads.ts
@@ -65,16 +65,6 @@ export const GetProjectionThreadInput = Schema.Struct({
});
export type GetProjectionThreadInput = typeof GetProjectionThreadInput.Type;
-export const DeleteProjectionThreadInput = Schema.Struct({
- threadId: ThreadId,
-});
-export type DeleteProjectionThreadInput = typeof DeleteProjectionThreadInput.Type;
-
-export const ListProjectionThreadsByProjectInput = Schema.Struct({
- projectId: ProjectId,
-});
-export type ListProjectionThreadsByProjectInput = typeof ListProjectionThreadsByProjectInput.Type;
-
/**
* ProjectionThreadRepositoryShape - Service API for projected thread records.
*/
@@ -92,22 +82,6 @@ export interface ProjectionThreadRepositoryShape {
readonly getById: (
input: GetProjectionThreadInput,
) => Effect.Effect, ProjectionRepositoryError>;
-
- /**
- * List projected threads for a project.
- *
- * Returned in deterministic creation order.
- */
- readonly listByProjectId: (
- input: ListProjectionThreadsByProjectInput,
- ) => Effect.Effect, ProjectionRepositoryError>;
-
- /**
- * Soft-delete a projected thread row by id.
- */
- readonly deleteById: (
- input: DeleteProjectionThreadInput,
- ) => Effect.Effect;
}
/**
diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts
index 620545569aaa..08bbd674e9b0 100644
--- a/apps/server/src/provider/acp/XAiAcpExtension.ts
+++ b/apps/server/src/provider/acp/XAiAcpExtension.ts
@@ -110,10 +110,6 @@ interface XAiAskUserQuestionCancelledResponse {
readonly outcome: "cancelled";
}
-export type XAiAskUserQuestionResponse =
- | XAiAskUserQuestionAcceptedResponse
- | XAiAskUserQuestionCancelledResponse;
-
interface NormalizedXAiAnswer {
readonly questionText: string;
readonly selectedLabels: ReadonlyArray;
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 512e55a64cbb..2618020d388d 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -123,10 +123,7 @@ import * as Keybindings from "./keybindings.ts";
import * as ExternalLauncher from "./process/externalLauncher.ts";
import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts";
import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
-import {
- OrchestrationListenerCallbackError,
- OrchestrationThreadSettleBlockedError,
-} from "./orchestration/Errors.ts";
+import { OrchestrationThreadSettleBlockedError } from "./orchestration/Errors.ts";
import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts";
import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
@@ -7358,8 +7355,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
Effect.flatMap(() =>
command.commandId === failedCommandId
? Effect.fail(
- new OrchestrationListenerCallbackError({
- listener: "domain-event",
+ new PersistenceSqlError({
+ operation: "OrchestrationEventStore.append:query",
detail: "thread creation failed",
}),
)
@@ -10682,8 +10679,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
effects.push(`dispatch:${command.type}`);
if (command.type === "thread.session.stop") {
return Effect.fail(
- new OrchestrationListenerCallbackError({
- listener: "domain-event",
+ new PersistenceSqlError({
+ operation: "OrchestrationEventStore.append:query",
detail: "simulated archive stop failure",
}),
);
@@ -11629,8 +11626,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
setupActivityAppendAttempt += 1;
if (setupActivityAppendAttempt === 2) {
return Effect.fail(
- new OrchestrationListenerCallbackError({
- listener: "domain-event",
+ new PersistenceSqlError({
+ operation: "OrchestrationEventStore.append:query",
detail: "failed to append setup-script.started activity",
}),
);
@@ -12155,8 +12152,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
dispatchedCommands.push(command);
if (command.type === "thread.delete") {
return Effect.fail(
- new OrchestrationListenerCallbackError({
- listener: "domain-event",
+ new PersistenceSqlError({
+ operation: "OrchestrationEventStore.append:query",
detail: "thread cleanup exploded",
}),
);
diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts
index 27fc28df7bf7..37634cea7e4e 100644
--- a/apps/server/src/textGeneration/TextGeneration.ts
+++ b/apps/server/src/textGeneration/TextGeneration.ts
@@ -10,8 +10,6 @@ import * as SourceControlProviderRegistry from "../sourceControl/SourceControlPr
import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
-export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode";
-
export interface CommitMessageGenerationInput {
cwd: string;
branch: string | null;
diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts
index cac501aede0c..a387dd189b3b 100644
--- a/apps/server/src/workspace/WorkspaceSearchIndex.ts
+++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts
@@ -107,12 +107,6 @@ export class WorkspaceSearchIndexDestroyFailed extends Schema.TaggedError ({
@@ -32,13 +32,16 @@ describe("managed relay authentication", () => {
it("clears all token access synchronously before account cleanup can fail", async () => {
activateManagedRelayAuthentication("account-1", async () => "account-1-token");
expect(appAtomRegistry.get(managedRelaySessionAtom)?.accountId).toBe("account-1");
- expect(await readManagedRelayClerkToken()).toBe("account-1-token");
+ const token = await Effect.fromNullishOr(appAtomRegistry.get(managedRelaySessionAtom)).pipe(
+ Effect.flatMap((session) => session.readClerkToken()),
+ Effect.runPromise,
+ );
+ expect(token).toBe("account-1-token");
deactivateManagedRelayAuthentication();
const cleanup = Promise.reject(new Error("Persistence removal failed.")).catch(() => undefined);
expect(appAtomRegistry.get(managedRelaySessionAtom)).toBeNull();
- expect(await readManagedRelayClerkToken()).toBeNull();
await cleanup;
});
diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx
index 2f631214501b..f9bf1c966f40 100644
--- a/apps/web/src/cloud/managedAuth.tsx
+++ b/apps/web/src/cloud/managedAuth.tsx
@@ -14,14 +14,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry";
import { useAtomCommand } from "../state/use-atom-command";
import { resolveRelayClerkTokenOptions } from "./publicConfig";
-let relayTokenProvider: (() => Promise) | null = null;
-
-export async function readManagedRelayClerkToken(): Promise {
- return relayTokenProvider?.() ?? null;
-}
-
export function deactivateManagedRelayAuthentication(): void {
- relayTokenProvider = null;
setManagedRelaySession(appAtomRegistry, null);
}
@@ -29,7 +22,6 @@ export function activateManagedRelayAuthentication(
accountId: string,
readClerkToken: () => Promise,
): void {
- relayTokenProvider = readClerkToken;
setManagedRelaySession(appAtomRegistry, {
accountId,
readClerkToken,
diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts
index 799bd0bde7f4..a2897ce3ddb0 100644
--- a/apps/web/src/components/ChatView.logic.test.ts
+++ b/apps/web/src/components/ChatView.logic.test.ts
@@ -32,7 +32,6 @@ import {
usePreviewMiniPlayerStore,
} from "../previewMiniPlayerStore";
import {
- MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
agentControlledBrowserCloseConfirmation,
branchMismatchKey,
@@ -52,7 +51,6 @@ import {
shouldRefocusComposerOnWindowFocus,
isBranchMismatchDismissedForSession,
reconcileMountedTerminalThreadIds,
- reconcileRetainedMountedThreadIds,
recallCheckoutIsRepo,
rememberCheckoutIsRepo,
resolveBackgroundDraftWorkspaceOptions,
@@ -1887,50 +1885,6 @@ describe("reconcileMountedTerminalThreadIds", () => {
});
});
-describe("reconcileRetainedMountedThreadIds", () => {
- it("retains hidden open threads and adds the active open thread", () => {
- expect(
- reconcileRetainedMountedThreadIds({
- currentThreadIds: [ThreadId.make("thread-hidden")],
- openThreadIds: [ThreadId.make("thread-hidden")],
- activeThreadId: ThreadId.make("thread-active"),
- activeThreadOpen: true,
- maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
- }),
- ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]);
- });
-
- it("can retain the active thread as hidden when it is inactive", () => {
- expect(
- reconcileRetainedMountedThreadIds({
- currentThreadIds: [ThreadId.make("thread-active")],
- openThreadIds: [ThreadId.make("thread-active")],
- activeThreadId: ThreadId.make("thread-active"),
- activeThreadOpen: false,
- maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
- retainInactiveActiveThread: true,
- }),
- ).toEqual([ThreadId.make("thread-active")]);
- });
-
- it("evicts the oldest hidden threads beyond the configured cap", () => {
- const currentThreadIds = Array.from(
- { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 },
- (_, index) => ThreadId.make(`thread-${index + 1}`),
- );
-
- expect(
- reconcileRetainedMountedThreadIds({
- currentThreadIds,
- openThreadIds: currentThreadIds,
- activeThreadId: null,
- activeThreadOpen: false,
- maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS,
- }),
- ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS));
- });
-});
-
describe("shouldWriteThreadErrorToCurrentServerThread", () => {
it("writes errors for a shell-derived active server thread", () => {
const routeThreadRef = { environmentId, threadId };
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index bc68a9fcfa01..928d99c549a6 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -60,7 +60,7 @@ import {
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
-export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3;
+
export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
@@ -678,31 +678,15 @@ export function reconcileMountedTerminalThreadIds(input: {
activeThreadId: string | null;
activeThreadTerminalOpen: boolean;
maxHiddenThreadCount?: number;
-}): string[] {
- return reconcileRetainedMountedThreadIds({
- currentThreadIds: input.currentThreadIds,
- openThreadIds: input.openThreadIds,
- activeThreadId: input.activeThreadId,
- activeThreadOpen: input.activeThreadTerminalOpen,
- maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
- });
-}
-
-export function reconcileRetainedMountedThreadIds(input: {
- currentThreadIds: ReadonlyArray;
- openThreadIds: ReadonlyArray;
- activeThreadId: string | null;
- activeThreadOpen: boolean;
- maxHiddenThreadCount: number;
- retainInactiveActiveThread?: boolean;
}): string[] {
const openThreadIdSet = new Set(input.openThreadIds);
const hiddenThreadIds = input.currentThreadIds.filter(
- (threadId) =>
- (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) &&
- openThreadIdSet.has(threadId),
+ (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
+ );
+ const maxHiddenThreadCount = Math.max(
+ 0,
+ input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
);
- const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount);
const nextThreadIds =
hiddenThreadIds.length > maxHiddenThreadCount
? hiddenThreadIds.slice(-maxHiddenThreadCount)
@@ -710,7 +694,7 @@ export function reconcileRetainedMountedThreadIds(input: {
if (
input.activeThreadId &&
- input.activeThreadOpen &&
+ input.activeThreadTerminalOpen &&
!nextThreadIds.includes(input.activeThreadId)
) {
nextThreadIds.push(input.activeThreadId);
diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts
index 23deb066a2ab..765e0b0a1b02 100644
--- a/apps/web/src/components/preview/PreviewPanelShell.test.ts
+++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts
@@ -1,8 +1,6 @@
-import { jsx } from "react/jsx-runtime";
-import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
-import { getPreviewPanelMaxWidth, PreviewPanelShell } from "./PreviewPanelShell";
+import { getPreviewPanelMaxWidth } from "./PreviewPanelShell";
describe("getPreviewPanelMaxWidth", () => {
it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => {
@@ -13,14 +11,6 @@ describe("getPreviewPanelMaxWidth", () => {
expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400);
});
- it("keeps inline panels inside their containing workspace", () => {
- const markup = renderToStaticMarkup(
- jsx(PreviewPanelShell, { mode: "inline", defaultWidth: 1_000, children: "Panel" }),
- );
-
- expect(markup).toContain("max-w-full");
- });
-
it("reserves the sibling column minimum when the flex row is known", () => {
// Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256.
// The 70% fraction (1058) would leave the chat column only ~198px;
diff --git a/apps/web/src/components/settings/settingsLayout.test.tsx b/apps/web/src/components/settings/settingsLayout.test.tsx
index 51bb90b33ab3..4195a359d1b4 100644
--- a/apps/web/src/components/settings/settingsLayout.test.tsx
+++ b/apps/web/src/components/settings/settingsLayout.test.tsx
@@ -1,12 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
-import {
- scrollToSettingsTarget,
- SettingsRow,
- SettingsSearchTargetProvider,
- SettingsUnavailableGroup,
-} from "./settingsLayout";
+import { scrollToSettingsTarget, SettingsRow, SettingsUnavailableGroup } from "./settingsLayout";
afterEach(() => {
vi.unstubAllGlobals();
@@ -27,19 +22,6 @@ describe("unavailable settings", () => {
});
describe("settings search targets", () => {
- it("does not persist destination styling in the rendered row", () => {
- const markup = renderToStaticMarkup(
-
-
-
- ,
- );
-
- expect(markup).toContain('id="word-wrap" tabindex="-1"');
- expect(markup).not.toContain("data-settings-search-target");
- expect(markup).not.toContain("settings-search-target-pulse");
- });
-
it("scrolls directly to a section header and restarts the destination pulse", () => {
const sectionScrollIntoView = vi.fn();
const headerScrollIntoView = vi.fn();
diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx
index 94938b637db7..c805e37f1bbd 100644
--- a/apps/web/src/components/settings/settingsLayout.tsx
+++ b/apps/web/src/components/settings/settingsLayout.tsx
@@ -57,7 +57,7 @@ const SettingsSearchTargetContext = createContext;
-}
-
function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
return (
- );
-}
-
function AutocompleteGroup({ className, ...props }: AutocompletePrimitive.Group.Props) {
return (
- );
-}
-
-function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
- return ;
-}
-
function AutocompleteList({ className, ...props }: AutocompletePrimitive.List.Props) {
return (
@@ -216,19 +196,6 @@ function AutocompleteClear({ className, ...props }: AutocompletePrimitive.Clear.
);
}
-function AutocompleteStatus({ className, ...props }: AutocompletePrimitive.Status.Props) {
- return (
-
- );
-}
-
function AutocompleteCollection({ ...props }: AutocompletePrimitive.Collection.Props) {
return ;
}
@@ -249,23 +216,16 @@ function AutocompleteTrigger({
);
}
-const useAutocompleteFilter = AutocompletePrimitive.useFilter;
-
export {
Autocomplete,
AutocompleteInput,
AutocompleteTrigger,
AutocompletePopup,
AutocompleteItem,
- AutocompleteSeparator,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
- AutocompleteValue,
AutocompleteList,
AutocompleteClear,
- AutocompleteStatus,
- AutocompleteRow,
AutocompleteCollection,
- useAutocompleteFilter,
};
diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx
index 863726f32413..42a3b815e2ff 100644
--- a/apps/web/src/components/ui/combobox.tsx
+++ b/apps/web/src/components/ui/combobox.tsx
@@ -28,31 +28,6 @@ function Combobox(
);
}
-function ComboboxChipsInput({
- className,
- size,
- ...props
-}: Omit & {
- size?: "sm" | "default" | "lg" | number;
- ref?: React.Ref;
-}) {
- const sizeValue = (size ?? "default") as "sm" | "default" | "lg" | number;
-
- return (
-
- );
-}
-
function ComboboxInput({
className,
inputClassName,
@@ -240,36 +215,6 @@ function ComboboxItem({
);
}
-function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
- return (
-
- );
-}
-
-function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
- return (
-
- );
-}
-
-function ComboboxGroupLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
- return (
-
- );
-}
-
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
;
-}
-
-function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
- return ;
-}
-
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
@@ -334,102 +271,19 @@ function ComboboxStatus({ className, ...props }: ComboboxPrimitive.Status.Props)
);
}
-function ComboboxCollection(props: ComboboxPrimitive.Collection.Props) {
- return ;
-}
-
-function ComboboxChips({
- className,
- children,
- startAddon,
- ...props
-}: ComboboxPrimitive.Chips.Props & {
- startAddon?: React.ReactNode;
-}) {
- const { chipsRef } = React.use(ComboboxContext);
-
- return (
- | null}
- {...props}
- >
- {startAddon && (
-
- {startAddon}
-
- )}
- {children}
-
- );
-}
-
-function ComboboxChip({ children, ...props }: ComboboxPrimitive.Chip.Props) {
- const labelId = React.useId();
-
- return (
-
- {children}
-
-
- );
-}
-
-function ComboboxChipRemove({
- labelId,
- ...props
-}: ComboboxPrimitive.ChipRemove.Props & { labelId: string }) {
- const removeLabelId = `${labelId}-remove`;
-
- return (
-
-
- Remove
-
-
-
- );
-}
-
const useComboboxFilter = ComboboxPrimitive.useFilter;
export {
Combobox,
- ComboboxChipsInput,
ComboboxInput,
ComboboxSearchInput,
ComboboxTrigger,
ComboboxPopup,
ComboboxItem,
- ComboboxSeparator,
- ComboboxGroup,
- ComboboxGroupLabel,
ComboboxEmpty,
- ComboboxValue,
ComboboxList,
ComboboxListVirtualized,
ComboboxClear,
ComboboxStatus,
- ComboboxRow,
- ComboboxCollection,
- ComboboxChips,
- ComboboxChip,
useComboboxFilter,
};
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx
index c1952e9c6f36..f8e9b72b1b4c 100644
--- a/apps/web/src/components/ui/command.tsx
+++ b/apps/web/src/components/ui/command.tsx
@@ -7,13 +7,11 @@ import { cn } from "~/lib/utils";
import {
Autocomplete,
AutocompleteCollection,
- AutocompleteEmpty,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
- AutocompleteSeparator,
} from "~/components/ui/autocomplete";
import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
import { Button } from "~/components/ui/button";
@@ -22,8 +20,6 @@ const CommandDialog = CommandDialogPrimitive.Root;
const CommandDialogPortal = CommandDialogPrimitive.Portal;
-const CommandCreateHandle = CommandDialogPrimitive.createHandle;
-
function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) {
return ;
}
@@ -135,16 +131,6 @@ function CommandList({ className, ...props }: React.ComponentProps) {
- return (
-
- );
-}
-
function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
- return (
-
- );
-}
-
function CommandShortcut({ className, ...props }: React.ComponentProps<"kbd">) {
return (
) {
- const defaultProps = {
- className: cn(
- "relative inline-flex items-center whitespace-nowrap gap-2 rounded-lg border border-input bg-muted not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-muted-foreground text-base sm:text-sm shadow-xs/5 outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/6%)] dark:bg-input/64 dark:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 [&_svg]:-mx-0.5",
- className,
- ),
- "data-slot": "group-text",
- };
- return useRender({
- defaultTagName: "div",
- props: mergeProps(defaultProps, props),
- render,
- });
-}
-
function GroupSeparator({
className,
orientation = "vertical",
@@ -85,8 +67,6 @@ function GroupSeparator({
export {
Group,
Group as ButtonGroup,
- GroupText,
- GroupText as ButtonGroupText,
GroupSeparator,
GroupSeparator as ButtonGroupSeparator,
groupVariants,
diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx
index 04e34e5611ee..9a9228ede61d 100644
--- a/apps/web/src/components/ui/input-group.tsx
+++ b/apps/web/src/components/ui/input-group.tsx
@@ -5,7 +5,6 @@ import type * as React from "react";
import { cn } from "~/lib/utils";
import { Input, type InputProps } from "~/components/ui/input";
-import { Textarea, type TextareaProps } from "~/components/ui/textarea";
const inputGroupVariants = cva(
"relative inline-flex w-full min-w-0 items-center rounded-[var(--control-radius)] border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--control-radius)-1px)]",
@@ -90,24 +89,8 @@ function InputGroupAddon({
);
}
-function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
function InputGroupInput({ className, ...props }: InputProps) {
return ;
}
-function InputGroupTextarea({ className, ...props }: TextareaProps) {
- return ;
-}
-
-export { InputGroup, InputGroupAddon, InputGroupText, InputGroupInput, InputGroupTextarea };
+export { InputGroup, InputGroupAddon, InputGroupInput };
diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx
index 6f35c1dc2784..ffcf0f374c87 100644
--- a/apps/web/src/components/ui/menu.tsx
+++ b/apps/web/src/components/ui/menu.tsx
@@ -7,12 +7,8 @@ import type * as React from "react";
import { cn } from "~/lib/utils";
import { buttonVariants } from "./button";
-const MenuCreateHandle = MenuPrimitive.createHandle;
-
const Menu = MenuPrimitive.Root;
-const MenuPortal = MenuPrimitive.Portal;
-
function MenuTrigger({ className, children, ...props }: MenuPrimitive.Trigger.Props) {
return (
@@ -304,12 +300,8 @@ function MenuSubPopup({
}
export {
- MenuCreateHandle,
- MenuCreateHandle as DropdownMenuCreateHandle,
Menu,
Menu as DropdownMenu,
- MenuPortal,
- MenuPortal as DropdownMenuPortal,
MenuTrigger,
MenuTrigger as DropdownMenuTrigger,
MenuPopup,
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
index 4b348f2ffed4..e1a758b0571d 100644
--- a/apps/web/src/components/ui/number-field.tsx
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -4,13 +4,6 @@ import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field
import { MinusIcon, PlusIcon } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
-import { Label } from "~/components/ui/label";
-
-export const NumberFieldContext: React.Context<{
- fieldId: string;
-} | null> = React.createContext<{
- fieldId: string;
-} | null>(null);
export function NumberField({
id,
@@ -22,18 +15,14 @@ export function NumberField({
}): React.ReactElement {
const generatedId = React.useId();
const fieldId = id ?? generatedId;
- const contextValue = React.useMemo(() => ({ fieldId }), [fieldId]);
-
return (
-
-
-
+
);
}
@@ -105,52 +94,4 @@ export function NumberFieldInput({
);
}
-export function NumberFieldScrubArea({
- className,
- label,
- ...props
-}: NumberFieldPrimitive.ScrubArea.Props & {
- label: string;
-}): React.ReactElement {
- const context = React.use(NumberFieldContext);
-
- if (!context) {
- throw new Error(
- "NumberFieldScrubArea must be used within a NumberField component for accessibility.",
- );
- }
-
- return (
-
-
-
-
-
-
- );
-}
-
-export function CursorGrowIcon(props: React.ComponentProps<"svg">): React.ReactElement {
- return (
-
- );
-}
-
export { NumberFieldPrimitive };
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 9534112e4e72..4bc76706ab2b 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -91,16 +91,6 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
);
}
-function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
- return (
-
- );
-}
-
export {
PopoverCreateHandle,
Popover,
@@ -108,6 +98,5 @@ export {
PopoverPopup,
PopoverPopup as PopoverContent,
PopoverTitle,
- PopoverDescription,
PopoverClose,
};
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 46b2c8eb1ba0..a7ab17eb9289 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -1,10 +1,7 @@
"use client";
-
-import { mergeProps } from "@base-ui/react/merge-props";
import { Select as SelectPrimitive } from "@base-ui/react/select";
-import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
-import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from "lucide-react";
+import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "~/lib/utils";
@@ -37,41 +34,6 @@ const selectTriggerVariants = cva(
},
);
-const selectTriggerIconClassName = "-me-1 size-4.5 opacity-80 sm:size-4";
-
-interface SelectButtonProps extends useRender.ComponentProps<"button"> {
- size?: VariantProps["size"];
- variant?: VariantProps["variant"];
-}
-
-function SelectButton({ className, size, variant, render, children, ...props }: SelectButtonProps) {
- const typeValue: React.ButtonHTMLAttributes["type"] = render
- ? undefined
- : "button";
-
- const defaultProps = {
- children: (
- <>
- {children}
- {variant === "ghost" ? (
-
- ) : (
-
- )}
- >
- ),
- className: cn(selectTriggerVariants({ size, variant }), "min-w-none", className),
- "data-slot": "select-button",
- type: typeValue,
- };
-
- return useRender({
- defaultTagName: "button",
- props: mergeProps<"button">(defaultProps, props),
- render,
- });
-}
-
function SelectTrigger({
className,
size = "default",
@@ -203,16 +165,6 @@ function SelectItem({
);
}
-function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
- return (
-
- );
-}
-
function SelectGroup(props: SelectPrimitive.Group.Props) {
return ;
}
@@ -230,13 +182,11 @@ function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props) {
export {
Select,
SelectTrigger,
- SelectButton,
selectTriggerVariants,
SelectValue,
SelectPopup,
SelectPopup as SelectContent,
SelectItem,
- SelectSeparator,
SelectGroup,
SelectGroupLabel,
};
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index f7711da80038..0b68ff116484 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -5,20 +5,11 @@ import { XIcon } from "lucide-react";
import type { CSSProperties } from "react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
-import { ScrollArea } from "~/components/ui/scroll-area";
const Sheet = SheetPrimitive.Root;
const SheetPortal = SheetPrimitive.Portal;
-function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
- return ;
-}
-
-function SheetClose(props: SheetPrimitive.Close.Props) {
- return ;
-}
-
function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
) {
);
}
-function SheetFooter({
- className,
- variant = "default",
- ...props
-}: React.ComponentProps<"div"> & {
- variant?: "default" | "bare";
-}) {
- return (
-
- );
-}
-
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
& { scrollFade?: boolean }) {
- return (
-
-
-
- );
-}
-
export {
Sheet,
- SheetTrigger,
SheetPortal,
- SheetClose,
SheetBackdrop,
SheetBackdrop as SheetOverlay,
SheetPopup,
SheetPopup as SheetContent,
SheetHeader,
- SheetFooter,
SheetTitle,
SheetDescription,
- SheetPanel,
};
diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx
index 784c5e087963..1a3113d5216a 100644
--- a/apps/web/src/components/ui/sidebar.test.tsx
+++ b/apps/web/src/components/ui/sidebar.test.tsx
@@ -1,23 +1,8 @@
-import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
-import {
- SidebarMenuButton,
- SidebarMenuSubButton,
- SidebarProvider,
- SidebarTrigger,
-} from "./sidebar";
import { resolveSidebarState } from "./sidebarState";
-function renderSidebarButton(className?: string) {
- return renderToStaticMarkup(
-
- Projects
- ,
- );
-}
-
-describe("sidebar interactive cursors", () => {
+describe("responsive sidebar state", () => {
it("uses mobile sheet visibility for the shared responsive state", () => {
expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe(
"collapsed",
@@ -27,73 +12,4 @@ describe("sidebar interactive cursors", () => {
"expanded",
);
});
-
- it("exposes collapsed state for shared titlebar inset styling", () => {
- const html = renderToStaticMarkup(
-
-
- ,
- );
-
- expect(html).toContain('data-sidebar-state="collapsed"');
- });
-
- it("keeps the sidebar trigger interactive inside Electron drag regions", () => {
- const html = renderToStaticMarkup(
-
-
- ,
- );
-
- expect(html).toContain("[-webkit-app-region:no-drag]");
- expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!");
- });
-
- it("uses shared geometry and icon constraints for menu buttons by default", () => {
- const html = renderSidebarButton();
-
- expect(html).toContain('data-slot="sidebar-menu-button"');
- expect(html).toContain("h-8");
- expect(html).toContain("rounded-[var(--control-radius)]");
- expect(html).toContain("px-[var(--sidebar-row-content-inset)]");
- expect(html).toContain("py-1.5");
- expect(html).toContain("]:size-4");
- expect(html).toContain("]:shrink-0");
- expect(html).toContain("cursor-pointer");
- expect(html).toContain("gap-[var(--sidebar-control-gap)]");
- expect(html).toContain("text-[var(--sidebar-icon-color)]");
- expect(html).not.toContain("[&>svg]:opacity-60");
- });
-
- it("applies the shared default treatment to icon-only menu buttons", () => {
- const html = renderToStaticMarkup(
-
-
- +
-
- ,
- );
-
- expect(html).toContain("size-8");
- expect(html).toContain("justify-center");
- expect(html).toContain("p-0");
- expect(html).toContain("font-medium");
- expect(html).toContain("text-sidebar-muted-foreground/80");
- });
-
- it("lets project drag handles override the default pointer cursor", () => {
- const html = renderSidebarButton("cursor-grab");
-
- expect(html).toContain("cursor-grab");
- expect(html).not.toContain("cursor-pointer");
- });
-
- it("uses a pointer cursor for submenu buttons", () => {
- const html = renderToStaticMarkup(
- }>Show more,
- );
-
- expect(html).toContain('data-slot="sidebar-menu-sub-button"');
- expect(html).toContain("cursor-pointer");
- });
});
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
index 307feda7abfc..32405fe72b06 100644
--- a/apps/web/src/components/ui/sidebar.tsx
+++ b/apps/web/src/components/ui/sidebar.tsx
@@ -5,9 +5,7 @@ import { PanelLeftCloseIcon, PanelLeftIcon } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
-import { Input } from "~/components/ui/input";
import { ScrollArea } from "~/components/ui/scroll-area";
-import { Separator } from "~/components/ui/separator";
import {
Sheet,
SheetDescription,
@@ -15,7 +13,6 @@ import {
SheetPopup,
SheetTitle,
} from "~/components/ui/sheet";
-import { Skeleton } from "~/components/ui/skeleton";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useResizeDrag } from "~/hooks/useResizeDrag";
import { useIsMobile } from "~/hooks/useMediaQuery";
@@ -544,17 +541,6 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
);
}
-function SidebarInput({ className, ...props }: React.ComponentProps) {
- return (
-
- );
-}
-
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
-function SidebarSeparator({ className, ...props }: React.ComponentProps
) {
- return (
-
- );
-}
-
function SidebarContent({
className,
fixedHeader,
@@ -635,55 +610,6 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
);
}
-function SidebarGroupLabel({ className, render, ...props }: useRender.ComponentProps<"div">) {
- const defaultProps = {
- className: cn(
- "flex h-8 shrink-0 items-center rounded-lg px-2 font-medium text-sidebar-foreground text-xs outline-hidden ring-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
- className,
- ),
- "data-sidebar": "group-label",
- "data-slot": "sidebar-group-label",
- };
-
- return useRender({
- defaultTagName: "div",
- props: mergeProps(defaultProps, props),
- render,
- });
-}
-
-function SidebarGroupAction({ className, render, ...props }: useRender.ComponentProps<"button">) {
- const defaultProps = {
- className: cn(
- "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:-inset-2 after:absolute md:after:hidden",
- "group-data-[collapsible=icon]:hidden",
- className,
- ),
- "data-sidebar": "group-action",
- "data-slot": "sidebar-group-action",
- };
-
- return useRender({
- defaultTagName: "button",
- props: mergeProps(defaultProps, props),
- render,
- });
-}
-
-function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
) {
- return (
-
- );
-}
-
-function SidebarMenuSkeleton({
- className,
- showIcon = false,
- ...props
-}: React.ComponentProps<"div"> & {
- showIcon?: boolean;
-}) {
- // Random width between 50 to 90%. Intentionally wrapped in useMemo to avoid changing width on every render.
- const width = React.useMemo(() => {
- return `${Math.floor(Math.random() * 40) + 50}%`;
- }, []);
-
- return (
-
- {showIcon && }
-
-
- );
-}
-
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
) {
);
}
-function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
- return (
- tr]:last:border-b-0", className)}
- {...props}
- />
- );
-}
-
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
) {
);
}
-function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
- return (
-
- );
-}
-
-export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
diff --git a/apps/web/src/components/ui/toggle-group.tsx b/apps/web/src/components/ui/toggle-group.tsx
index 23501ec7a96c..846a34a1086a 100644
--- a/apps/web/src/components/ui/toggle-group.tsx
+++ b/apps/web/src/components/ui/toggle-group.tsx
@@ -6,7 +6,6 @@ import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils";
-import { Separator } from "~/components/ui/separator";
import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle";
const ToggleGroupContext = React.createContext>({
@@ -76,23 +75,4 @@ function Toggle({
);
}
-function ToggleGroupSeparator({
- className,
- orientation = "vertical",
- ...props
-}: {
- className?: string;
-} & React.ComponentProps) {
- return (
-
- );
-}
-
-export { ToggleGroup, Toggle, Toggle as ToggleGroupItem, ToggleGroupSeparator };
+export { ToggleGroup, Toggle, Toggle as ToggleGroupItem };
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 77b15a01e16b..c0aaea40ceae 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -2,8 +2,6 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "~/lib/utils";
-const TooltipCreateHandle = TooltipPrimitive.createHandle;
-
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
@@ -61,4 +59,4 @@ function TooltipPopup({
);
}
-export { TooltipCreateHandle, TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
+export { TooltipProvider, Tooltip, TooltipTrigger, TooltipPopup };
diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts
index 3e809e17f588..cfd834a56541 100644
--- a/apps/web/src/providerInstances.test.ts
+++ b/apps/web/src/providerInstances.test.ts
@@ -9,7 +9,6 @@ import {
isProviderInstancePickerVisible,
resolveDefaultProviderModelSelection,
resolveSelectableProviderInstance,
- resolveProviderDriverKindForInstanceSelection,
} from "./providerInstances";
function provider(input: {
@@ -362,44 +361,6 @@ describe("resolveSelectableProviderInstance", () => {
});
});
-describe("resolveProviderDriverKindForInstanceSelection", () => {
- it("maps custom provider instance ids back to their driver kind", () => {
- const providers = [
- provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }),
- provider({
- provider: ProviderDriverKind.make("claudeAgent"),
- instanceId: "claude_openrouter",
- displayName: "Claude OpenRouter",
- }),
- ];
- const entries = deriveProviderInstanceEntries(providers);
-
- expect(
- resolveProviderDriverKindForInstanceSelection(
- entries,
- providers,
- ProviderInstanceId.make("claude_openrouter"),
- ),
- ).toBe("claudeAgent");
- });
-
- it("does not guess a provider kind when the instance selection is unknown", () => {
- const providers = [
- provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }),
- provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }),
- ];
- const entries = deriveProviderInstanceEntries(providers);
-
- expect(
- resolveProviderDriverKindForInstanceSelection(
- entries,
- providers,
- ProviderInstanceId.make("removed_instance"),
- ),
- ).toBeUndefined();
- });
-});
-
describe("getDefaultProviderInstanceModel", () => {
it("uses the instance's own models, not the default instance of the kind", () => {
const providers = [
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index 9230ab0a489e..88ea8a257240 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -298,21 +298,3 @@ export function resolveDefaultProviderModelSelection(
const model = getDefaultProviderInstanceModel(providers, instanceId);
return model ? { instanceId, model } : null;
}
-
-/**
- * Resolve an open model-selection routing key back to a driver kind.
- * Custom instance ids such as `claude_openrouter` are not themselves
- * driver-kind slugs, but the composer still needs the owning driver kind
- * for capabilities, options, icons, and turn dispatch metadata.
- */
-export function resolveProviderDriverKindForInstanceSelection(
- entries: ReadonlyArray,
- providers: ReadonlyArray,
- selection: ProviderInstanceId | ProviderDriverKind | null | undefined,
-): ProviderDriverKind | undefined {
- const matchedEntry = entries.find((entry) => entry.instanceId === selection);
- if (matchedEntry) {
- return matchedEntry.driverKind;
- }
- return undefined;
-}
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index 41ed6560fe5c..381333fd29ab 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -5,7 +5,6 @@ import type {
ChatUnknownAttachment as ContractChatUnknownAttachment,
OrchestrationCheckpointFile,
OrchestrationCheckpointSummary,
- OrchestrationLatestTurn,
OrchestrationMessage,
OrchestrationProposedPlan,
OrchestrationSession,
@@ -86,9 +85,5 @@ export type Project = EnvironmentProject;
export type Thread = EnvironmentThread;
export type ThreadShell = EnvironmentThreadShell;
-export interface ThreadTurnState {
- latestTurn: OrchestrationLatestTurn | null;
-}
-
export type SidebarThreadSummary = EnvironmentThreadShell;
export type ThreadSession = OrchestrationSession;
diff --git a/apps/web/src/vite-plus-browser-matchers.d.ts b/apps/web/src/vite-plus-browser-matchers.d.ts
deleted file mode 100644
index 5f600145e4e1..000000000000
--- a/apps/web/src/vite-plus-browser-matchers.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { ExpectPollOptions } from "vite-plus/test";
-import type { Locator } from "vite-plus/test/browser";
-
-declare module "vite-plus/test" {
- interface ExpectStatic {
- element: (
- element: HTMLElement | SVGElement | null | Locator,
- options?: ExpectPollOptions,
- ) => any;
- }
-}
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
index ae3d4e4ad24d..366e387f1e6f 100644
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
@@ -84,7 +84,6 @@ function makeEnvironmentLinks(
): EnvironmentLinks.EnvironmentLinks["Service"] {
return {
upsert: () => Effect.void,
- listUsersForEnvironment: () => Effect.succeed(["dev:julius"]),
listDeliveryUsersForEnvironment: () =>
Effect.succeed([
{
@@ -93,7 +92,6 @@ function makeEnvironmentLinks(
liveActivitiesEnabled: true,
},
]),
- listPublicKeysForEnvironment: () => Effect.succeed([]),
listForUser: () => Effect.succeed([]),
getForUser: () => Effect.succeed(null),
revokeForUser: () => Effect.succeed(false),
diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts
index 17a2fe197b16..93c47af02e36 100644
--- a/infra/relay/src/agentActivity/ApnsClient.ts
+++ b/infra/relay/src/agentActivity/ApnsClient.ts
@@ -9,7 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import { ApnsEnvironment as ApnsEnvironmentSchema, type ApnsCredentials } from "../Config.ts";
import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
-import { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
+import type { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
@@ -71,12 +71,7 @@ export class ApnsHttpRequestError extends Schema.TaggedError Effect.void,
- listUsersForEnvironment: () => Effect.succeed(["user"]),
listDeliveryUsersForEnvironment: (input) =>
Effect.sync(() =>
current.linked && !current.revokedEnvironments.includes(input.environmentId)
@@ -177,7 +176,6 @@ function harness() {
]
: [],
),
- listPublicKeysForEnvironment: () => Effect.succeed([]),
listForUser: () => Effect.succeed([]),
revokeForUser: () => Effect.succeed(false),
getForUser: (input) =>
diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts
index 74d5c3740a51..457a545f74b0 100644
--- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts
+++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts
@@ -106,7 +106,6 @@ function makeEnvironmentLinks(
): EnvironmentLinks.EnvironmentLinks["Service"] {
return {
upsert: () => Effect.void,
- listUsersForEnvironment: () => Effect.succeed(["dev:julius"]),
listDeliveryUsersForEnvironment: () =>
Effect.succeed([
{
@@ -115,7 +114,6 @@ function makeEnvironmentLinks(
liveActivitiesEnabled: true,
},
]),
- listPublicKeysForEnvironment: () => Effect.succeed([]),
listForUser: () => Effect.succeed([]),
getForUser: () => Effect.succeed(null),
revokeForUser: () => Effect.succeed(false),
diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts
index 7f536bafb375..da6f4acc192e 100644
--- a/infra/relay/src/environments/EnvironmentConnector.test.ts
+++ b/infra/relay/src/environments/EnvironmentConnector.test.ts
@@ -209,9 +209,7 @@ function makeLinks(
): EnvironmentLinks.EnvironmentLinks["Service"] {
return {
upsert: () => Effect.void,
- listUsersForEnvironment: () => Effect.succeed([]),
listDeliveryUsersForEnvironment: () => Effect.succeed([]),
- listPublicKeysForEnvironment: () => Effect.succeed([environmentKeyPair.publicKey]),
listForUser: () => Effect.succeed([]),
getForUser: () =>
Effect.succeed({
diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts
index c0811e82d923..78412e6c138e 100644
--- a/infra/relay/src/environments/EnvironmentLinker.test.ts
+++ b/infra/relay/src/environments/EnvironmentLinker.test.ts
@@ -123,9 +123,7 @@ function testLayer(input?: {
}),
Layer.succeed(EnvironmentLinks.EnvironmentLinks, {
upsert: input?.upsert ?? (() => Effect.void),
- listUsersForEnvironment: () => Effect.succeed([]),
listDeliveryUsersForEnvironment: () => Effect.succeed([]),
- listPublicKeysForEnvironment: () => Effect.succeed([]),
listForUser: () => Effect.succeed([]),
getForUser: () => Effect.succeed(null),
revokeForUser: () => Effect.succeed(false),
diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts
index dccb9e39f60f..0a856bcaf7f5 100644
--- a/infra/relay/src/environments/EnvironmentLinks.test.ts
+++ b/infra/relay/src/environments/EnvironmentLinks.test.ts
@@ -66,7 +66,7 @@ describe("EnvironmentLinks", () => {
expect(error).toMatchObject({
_tag: "EnvironmentLinkUserListPersistenceError",
- operation: "list-delivery-users",
+ message: "Environment link user query 'list-delivery-users' failed for environment 'env-1'",
environmentId: "env-1",
});
expect(error.cause).toBe(cause);
@@ -78,7 +78,7 @@ describe("EnvironmentLinks", () => {
);
});
- it.effect("selects users when either notifications or Live Activities are enabled", () => {
+ it.effect("selects notification or Live Activity users by environment key", () => {
const whereConditions: Array = [];
const fakeDb = {
select: (selection: unknown) => {
@@ -99,7 +99,12 @@ describe("EnvironmentLinks", () => {
return Effect.gen(function* () {
const links = yield* EnvironmentLinks.EnvironmentLinks;
- expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]);
+ expect(
+ yield* links.listDeliveryUsersForEnvironment({
+ environmentId: "env-1",
+ environmentPublicKey: "public-key-1",
+ }),
+ ).toEqual([]);
expect(whereConditions).toHaveLength(1);
const query = new PgDialect().sqlToQuery(whereConditions[0] as never);
@@ -107,8 +112,9 @@ describe("EnvironmentLinks", () => {
expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null');
expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2');
expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3');
+ expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $4');
expect(query.sql).toContain(" or ");
- expect(query.params).toEqual(["env-1", true, true]);
+ expect(query.params).toEqual(["env-1", true, true, "public-key-1"]);
}).pipe(
Effect.provide(
EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))),
diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts
index 58347245d64d..f41794d60538 100644
--- a/infra/relay/src/environments/EnvironmentLinks.ts
+++ b/infra/relay/src/environments/EnvironmentLinks.ts
@@ -41,25 +41,12 @@ export class EnvironmentLinkUpsertPersistenceError extends Schema.TaggedError()(
"EnvironmentLinkUserListPersistenceError",
{
- operation: Schema.Literals(["list-users", "list-delivery-users"]),
environmentId: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
- return `Environment link user query '${this.operation}' failed for environment '${this.environmentId}'`;
- }
-}
-
-export class EnvironmentPublicKeyListPersistenceError extends Schema.TaggedError()(
- "EnvironmentPublicKeyListPersistenceError",
- {
- environmentId: Schema.String,
- cause: Schema.Defect(),
- },
-) {
- override get message(): string {
- return `Failed to list public keys for environment '${this.environmentId}'`;
+ return `Environment link user query 'list-delivery-users' failed for environment '${this.environmentId}'`;
}
}
@@ -110,9 +97,6 @@ export class EnvironmentLinks extends Context.Service<
readonly proof: RelayEnvironmentLinkProofPayload;
readonly endpoint: RelayManagedEndpoint;
}) => Effect.Effect;
- readonly listUsersForEnvironment: (input: {
- readonly environmentId: string;
- }) => Effect.Effect, EnvironmentLinkUserListPersistenceError>;
readonly listDeliveryUsersForEnvironment: (input: {
readonly environmentId: string;
readonly environmentPublicKey: string;
@@ -120,9 +104,6 @@ export class EnvironmentLinks extends Context.Service<
ReadonlyArray,
EnvironmentLinkUserListPersistenceError
>;
- readonly listPublicKeysForEnvironment: (input: {
- readonly environmentId: string;
- }) => Effect.Effect, EnvironmentPublicKeyListPersistenceError>;
readonly listForUser: (input: {
readonly userId: string;
}) => Effect.Effect<
@@ -140,23 +121,17 @@ export class EnvironmentLinks extends Context.Service<
}
>()("t3code-relay/environments/EnvironmentLinks") {}
-function agentAwarenessDeliveryUserCondition(environmentId: string) {
+function agentAwarenessDeliveryUserKeyCondition(input: {
+ readonly environmentId: string;
+ readonly environmentPublicKey: string;
+}) {
return and(
- eq(relayEnvironmentLinks.environmentId, environmentId),
+ eq(relayEnvironmentLinks.environmentId, input.environmentId),
isNull(relayEnvironmentLinks.revokedAt),
or(
eq(relayEnvironmentLinks.notificationsEnabled, true),
eq(relayEnvironmentLinks.liveActivitiesEnabled, true),
),
- );
-}
-
-function agentAwarenessDeliveryUserKeyCondition(input: {
- readonly environmentId: string;
- readonly environmentPublicKey: string;
-}) {
- return and(
- agentAwarenessDeliveryUserCondition(input.environmentId),
eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
);
}
@@ -220,27 +195,6 @@ const make = Effect.gen(function* () {
);
}),
- listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")(
- function* (input) {
- yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
- return yield* db
- .select({ userId: relayEnvironmentLinks.userId })
- .from(relayEnvironmentLinks)
- .where(agentAwarenessDeliveryUserCondition(input.environmentId))
- .pipe(
- Effect.map((rows) => rows.map((row) => row.userId)),
- Effect.mapError(
- (cause) =>
- new EnvironmentLinkUserListPersistenceError({
- operation: "list-users",
- environmentId: input.environmentId,
- cause,
- }),
- ),
- );
- },
- ),
-
listDeliveryUsersForEnvironment: Effect.fn(
"relay.environment_links.list_delivery_users_for_environment",
)(function* (input) {
@@ -264,34 +218,6 @@ const make = Effect.gen(function* () {
Effect.mapError(
(cause) =>
new EnvironmentLinkUserListPersistenceError({
- operation: "list-delivery-users",
- environmentId: input.environmentId,
- cause,
- }),
- ),
- );
- }),
-
- listPublicKeysForEnvironment: Effect.fn(
- "relay.environment_links.list_public_keys_for_environment",
- )(function* (input) {
- yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId });
- return yield* db
- .select({ environmentPublicKey: relayEnvironmentLinks.environmentPublicKey })
- .from(relayEnvironmentLinks)
- .where(
- and(
- eq(relayEnvironmentLinks.environmentId, input.environmentId),
- isNull(relayEnvironmentLinks.revokedAt),
- ),
- )
- .pipe(
- Effect.map((rows) => [
- ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)),
- ]),
- Effect.mapError(
- (cause) =>
- new EnvironmentPublicKeyListPersistenceError({
environmentId: input.environmentId,
cause,
}),
diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts
index edaf1d08ed7a..af220de52619 100644
--- a/infra/relay/src/http/Api.test.ts
+++ b/infra/relay/src/http/Api.test.ts
@@ -319,9 +319,7 @@ function relayUnlinkTestLayer(input?: {
EnvironmentLinks.EnvironmentLinks,
EnvironmentLinks.EnvironmentLinks.of({
upsert: () => Effect.die("unused upsert"),
- listUsersForEnvironment: () => Effect.die("unused listUsersForEnvironment"),
listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),
- listPublicKeysForEnvironment: () => Effect.die("unused listPublicKeysForEnvironment"),
listForUser: () => Effect.die("unused listForUser"),
getForUser: input?.getForUser ?? (() => Effect.succeed(null)),
revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),
diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts
index 5a4d0c9ca40c..fbe867b7792a 100644
--- a/infra/relay/src/http/Api.ts
+++ b/infra/relay/src/http/Api.ts
@@ -1041,7 +1041,6 @@ const RelayCommonPersistenceError = Schema.Union([
Devices.DeviceListPersistenceError,
LiveActivities.LiveActivityRegistrationPersistenceError,
EnvironmentLinks.EnvironmentLinkUserListPersistenceError,
- EnvironmentLinks.EnvironmentPublicKeyListPersistenceError,
EnvironmentLinks.EnvironmentLinkListPersistenceError,
EnvironmentLinks.EnvironmentLinkLookupPersistenceError,
EnvironmentLinks.EnvironmentLinkRevokePersistenceError,
diff --git a/packages/client-runtime/src/authorization/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts
index 0df4bab35ed8..ec2861de91ea 100644
--- a/packages/client-runtime/src/authorization/remote.test.ts
+++ b/packages/client-runtime/src/authorization/remote.test.ts
@@ -10,7 +10,6 @@ import {
appendClientConnectionParams,
bootstrapRemoteBearerSession,
exchangeRemoteDpopAccessToken,
- fetchRemoteDpopSessionState,
fetchRemoteSessionState,
issueRemoteDpopWebSocketTicket,
issueRemoteWebSocketTicket,
@@ -380,45 +379,6 @@ describe("remote environment authorization", () => {
}),
);
- it.effect("loads remote session state with a DPoP-bound access token", () =>
- Effect.gen(function* () {
- const fetch = recordedFetch(
- Response.json({
- authenticated: true,
- auth: {
- policy: "remote-reachable",
- bootstrapMethods: ["one-time-token"],
- sessionMethods: ["dpop-access-token"],
- sessionCookieName: "t3_session",
- },
- sessionMethod: "dpop-access-token",
- scopes: [
- "orchestration:read",
- "orchestration:operate",
- "terminal:operate",
- "review:write",
- ],
- expiresAt: "2026-05-01T12:00:00.000Z",
- }),
- );
-
- yield* fetchRemoteDpopSessionState({
- httpBaseUrl: "https://remote.example.com/",
- accessToken: "dpop-access-token",
- dpopProof: "dpop-proof",
- }).pipe(provideRemoteHttp(fetch.fetchFn));
-
- expectFetchCall(fetch.calls, 1, {
- url: "https://remote.example.com/api/auth/session",
- method: "GET",
- headers: {
- authorization: "DPoP dpop-access-token",
- dpop: "dpop-proof",
- },
- });
- }),
- );
-
it.effect("fails hung fetch requests on the configured timeout", () =>
Effect.gen(function* () {
const fetch = hangingFetch();
diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts
index be863e189a9e..425d95d3ac17 100644
--- a/packages/client-runtime/src/authorization/remote.ts
+++ b/packages/client-runtime/src/authorization/remote.ts
@@ -158,27 +158,6 @@ export const fetchRemoteSessionState = Effect.fn(
);
});
-export const fetchRemoteDpopSessionState = Effect.fn(
- "clientRuntime.authorization.fetchRemoteDpopSessionState",
-)(function* (input: {
- readonly httpBaseUrl: string;
- readonly accessToken: string;
- readonly dpopProof: string;
- readonly timeoutMs?: number;
-}) {
- const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth");
- return yield* executeEnvironmentHttpRequest(
- environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"),
- input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
- client.session({
- headers: {
- authorization: `DPoP ${input.accessToken}`,
- dpop: input.dpopProof,
- },
- }),
- );
-});
-
export const issueRemoteWebSocketTicket = Effect.fn(
"clientRuntime.authorization.issueRemoteWebSocketTicket",
)(function* (input: {
diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 825dca87b631..70888f2988e6 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -189,14 +189,6 @@ export const ExecutionEnvironmentDescriptor = Schema.Struct({
});
export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type;
-export const EnvironmentConnectionState = Schema.Literals([
- "connecting",
- "connected",
- "disconnected",
- "error",
-]);
-export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type;
-
export const RepositoryIdentityLocator = Schema.Struct({
source: Schema.Literal("git-remote"),
remoteName: TrimmedNonEmptyString,
@@ -228,9 +220,3 @@ export const ScopedThreadRef = Schema.Struct({
threadId: ThreadId,
});
export type ScopedThreadRef = typeof ScopedThreadRef.Type;
-
-export const ScopedThreadSessionRef = Schema.Struct({
- environmentId: EnvironmentId,
- threadId: ThreadId,
-});
-export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type;
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index e1af4f936141..7e738b97786b 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -1,92 +1,15 @@
-import type {
- VcsCreateRefInput,
- VcsCreateRefResult,
- VcsCreateWorktreeInput,
- VcsCreateWorktreeResult,
- VcsInitInput,
- VcsListRefsInput,
- VcsListRefsResult,
- VcsPullInput,
- VcsPullResult,
- VcsRemoveWorktreeInput,
- VcsSwitchRefInput,
- VcsSwitchRefResult,
- GitPreparePullRequestThreadInput,
- GitPreparePullRequestThreadResult,
- GitPullRequestRefInput,
- GitResolvePullRequestResult,
- VcsStatusInput,
- VcsStatusResult,
-} from "./git.ts";
-import type {
- ReviewDiffFileContentsInput,
- ReviewDiffFileContentsResult,
- ReviewDiffPreviewInput,
- ReviewDiffPreviewResult,
-} from "./review.ts";
-import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
-import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
-import type {
- ProjectListEntriesInput,
- ProjectListEntriesResult,
- ProjectReadFileInput,
- ProjectReadFileResult,
- ProjectSearchEntriesInput,
- ProjectSearchEntriesResult,
- ProjectWriteFileInput,
- ProjectWriteFileResult,
-} from "./project.ts";
-import type {
- TerminalAttachInput,
- TerminalAttachStreamEvent,
- TerminalClearInput,
- TerminalCloseInput,
- TerminalMetadataStreamEvent,
- TerminalOpenInput,
- TerminalResizeInput,
- TerminalRestartInput,
- TerminalSessionSnapshot,
- TerminalWriteInput,
-} from "./terminal.ts";
import * as Schema from "effect/Schema";
-import type {
- DiscoveredLocalServerList,
- PreviewCloseInput,
- PreviewEvent,
- PreviewListInput,
- PreviewListResult,
- PreviewNavigateInput,
- PreviewOpenInput,
- PreviewRefreshInput,
- PreviewReportStatusInput,
- PreviewResizeInput,
- PreviewSessionSnapshot,
-} from "./preview.ts";
+
import {
PreviewAutomationClickInput,
PreviewAutomationEvaluateInput,
- PreviewAutomationHost,
- PreviewAutomationHostFocus,
PreviewAutomationPressInput,
- PreviewAutomationResponse,
PreviewAutomationScrollInput,
PreviewAutomationSnapshot,
PreviewAutomationStatus,
- PreviewAutomationStreamEvent,
PreviewAutomationTypeInput,
PreviewAutomationWaitForInput,
} from "./previewAutomation.ts";
-import type {
- ClientOrchestrationCommand,
- OrchestrationGetFullThreadDiffInput,
- OrchestrationGetFullThreadDiffResult,
- OrchestrationGetTurnDiffInput,
- OrchestrationGetTurnDiffResult,
- OrchestrationShellSnapshot,
- OrchestrationShellStreamItem,
- OrchestrationSubscribeThreadInput,
- OrchestrationThreadStreamItem,
-} from "./orchestration.ts";
import { SnapShotSource } from "./orchestration.ts";
import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts";
import { BrowserProfileId } from "./browserProfile.ts";
@@ -100,14 +23,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";
import { ExecutionEnvironmentDescriptor } from "./environment.ts";
import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";
import type { EditorId } from "./editor.ts";
-import type {
- SourceControlCloneRepositoryInput,
- SourceControlCloneRepositoryResult,
- SourceControlPublishRepositoryInput,
- SourceControlPublishRepositoryResult,
- SourceControlRepositoryInfo,
- SourceControlRepositoryLookupInput,
-} from "./sourceControl.ts";
+
import type {
DesktopAppActivationRequest,
DesktopAppActivationResponse,
@@ -694,19 +610,6 @@ export interface DesktopPreviewFavicon {
capturedAt: number;
}
-export const DesktopPreviewFaviconSchema: Schema.Codec = Schema.Struct({
- dataUrl: Schema.String.check(
- Schema.isMaxLength(FAVICON_DATA_URL_MAX_LENGTH),
- Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
- ),
- pageUrl: Schema.String.check(Schema.isMaxLength(2_048)),
- capturedAt: Schema.Number.check(
- Schema.isFinite(),
- Schema.isGreaterThanOrEqualTo(0),
- Schema.isLessThanOrEqualTo(FAVICON_CAPTURED_AT_MAX),
- ),
-});
-
export interface DesktopPreviewTabState {
tabId: string;
webContentsId: number | null;
@@ -746,27 +649,6 @@ export const DesktopPreviewAutomationStatusSchema = Schema.Struct({
});
export type DesktopPreviewAutomationStatus = typeof DesktopPreviewAutomationStatusSchema.Type;
-export const DesktopPreviewNavStatusSchema = Schema.Union([
- Schema.Struct({ kind: Schema.Literal("Idle") }),
- Schema.Struct({
- kind: Schema.Literal("Loading"),
- url: Schema.String,
- title: Schema.String,
- }),
- Schema.Struct({
- kind: Schema.Literal("Success"),
- url: Schema.String,
- title: Schema.String,
- }),
- Schema.Struct({
- kind: Schema.Literal("LoadFailed"),
- url: Schema.String,
- title: Schema.String,
- code: Schema.Number,
- description: Schema.String,
- }),
-]);
-
export interface DesktopPreviewPointerEvent {
tabId: string;
phase: "move" | "click";
@@ -1474,135 +1356,3 @@ export interface LocalApi {
setClientSettings: (settings: ClientSettings) => Promise;
};
}
-
-/**
- * APIs bound to a specific backend environment connection.
- *
- * These operations must always be routed with explicit environment context.
- * They represent remote stateful capabilities such as orchestration, terminal,
- * project, VCS, and provider operations. In multi-environment mode, each environment gets
- * its own instance of this surface, and callers should resolve it by
- * `environmentId` rather than reaching through the local desktop bridge.
- */
-export interface EnvironmentApi {
- terminal: {
- open: (input: typeof TerminalOpenInput.Encoded) => Promise;
- attach: (
- input: typeof TerminalAttachInput.Encoded,
- callback: (event: TerminalAttachStreamEvent) => void,
- options?: {
- onResubscribe?: () => void;
- },
- ) => () => void;
- write: (input: typeof TerminalWriteInput.Encoded) => Promise;
- resize: (input: typeof TerminalResizeInput.Encoded) => Promise;
- clear: (input: typeof TerminalClearInput.Encoded) => Promise;
- restart: (input: typeof TerminalRestartInput.Encoded) => Promise;
- close: (input: typeof TerminalCloseInput.Encoded) => Promise;
- onMetadata: (
- callback: (event: TerminalMetadataStreamEvent) => void,
- options?: {
- onResubscribe?: () => void;
- },
- ) => () => void;
- };
- projects: {
- listEntries: (input: ProjectListEntriesInput) => Promise;
- readFile: (input: ProjectReadFileInput) => Promise;
- searchEntries: (input: ProjectSearchEntriesInput) => Promise;
- writeFile: (input: ProjectWriteFileInput) => Promise;
- };
- filesystem: {
- browse: (input: FilesystemBrowseInput) => Promise;
- };
- assets: {
- createUrl: (input: AssetCreateUrlInput) => Promise;
- };
- sourceControl: {
- lookupRepository: (
- input: SourceControlRepositoryLookupInput,
- ) => Promise