authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 15:05:58-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logc2b9680a5efc0855d24fb5a4bdcf9320469354d4
treea07436d65c5e019f4da1b97dbe62a18eb3a65ea7
parenta973e43421c9829a7450a7fe06d5f0e81c1b98c1

buuuunch of cleanup


69 files changed, 1386 insertions(+), 1101 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts-6
......@@ -6,7 +6,6 @@ import { execFileSync } from "node:child_process";
66import { NodeServices } from "@effect/platform-node";
77import {
88 ApprovalRequestId,
9 CheckpointRef,
109 type OrchestrationEvent,
1110 type OrchestrationThread,
1211} from "@t3tools/contracts";
......@@ -63,11 +62,6 @@ function initializeGitWorkspace(cwd: string) {
6362 runGit(cwd, ["commit", "-m", "Initial"]);
6463}
6564
66export function checkpointRefForTurn(threadId: string, turnCount: number): CheckpointRef {
67 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
68 return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`);
69}
70
7165export function gitRefExists(cwd: string, ref: string): boolean {
7266 try {
7367 runGit(cwd, ["show-ref", "--verify", "--quiet", ref]);
apps/server/integration/orchestrationEngine.integration.test.ts+32-20
......@@ -17,17 +17,15 @@ import { Effect, Option } from "effect";
1717
1818import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
1919import {
20 checkpointRefForTurn,
2120 gitRefExists,
2221 gitShowFileAtRef,
2322 makeOrchestrationIntegrationHarness,
2423 type OrchestrationIntegrationHarness,
2524} from "./OrchestrationEngineHarness.integration.ts";
25import { checkpointRefForThreadTurn } from "../src/checkpointing/Refs.ts";
2626
27const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
2827const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
2928const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
30const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
3129const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
3230const asProviderSessionId = (value: string): ProviderSessionId =>
3331 ProviderSessionId.makeUnsafe(value);
......@@ -37,7 +35,7 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
3735 ApprovalRequestId.makeUnsafe(value);
3836
3937const PROJECT_ID = asProjectId("project-1");
40const THREAD_ID = asThreadId("thread-1");
38const THREAD_ID = ThreadId.makeUnsafe("thread-1");
4139const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
4240const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
4341const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
......@@ -95,7 +93,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
9593
9694 yield* harness.engine.dispatch({
9795 type: "project.create",
98 commandId: asCommandId("cmd-project-create"),
96 commandId: CommandId.makeUnsafe("cmd-project-create"),
9997 projectId: PROJECT_ID,
10098 title: "Integration Project",
10199 workspaceRoot: harness.workspaceDir,
......@@ -105,7 +103,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
105103
106104 yield* harness.engine.dispatch({
107105 type: "thread.create",
108 commandId: asCommandId("cmd-thread-create"),
106 commandId: CommandId.makeUnsafe("cmd-thread-create"),
109107 threadId: THREAD_ID,
110108 projectId: PROJECT_ID,
111109 title: "Integration Thread",
......@@ -124,7 +122,7 @@ const startTurn = (input: {
124122}) =>
125123 input.harness.engine.dispatch({
126124 type: "thread.turn.start",
127 commandId: asCommandId(input.commandId),
125 commandId: CommandId.makeUnsafe(input.commandId),
128126 threadId: THREAD_ID,
129127 message: {
130128 messageId: asMessageId(input.messageId),
......@@ -195,8 +193,8 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
195193
196194 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed");
197195
198 const ref0 = checkpointRefForTurn(THREAD_ID, 0);
199 const ref1 = checkpointRefForTurn(THREAD_ID, 1);
196 const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);
197 const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);
200198 assert.equal(gitRefExists(harness.workspaceDir, ref0), true);
201199 assert.equal(gitRefExists(harness.workspaceDir, ref1), true);
202200 assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n");
......@@ -335,26 +333,34 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
335333
336334 const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({
337335 cwd: harness.workspaceDir,
338 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1),
339 toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),
336 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),
337 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
340338 fallbackFromToHead: false,
341339 });
342340 assert.equal(incrementalDiff.includes("README.md"), true);
343341
344342 const fullDiff = yield* harness.checkpointStore.diffCheckpoints({
345343 cwd: harness.workspaceDir,
346 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 0),
347 toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),
344 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0),
345 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
348346 fallbackFromToHead: false,
349347 });
350348 assert.equal(fullDiff.includes("README.md"), true);
351349
352350 assert.equal(
353 gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"),
351 gitShowFileAtRef(
352 harness.workspaceDir,
353 checkpointRefForThreadTurn(THREAD_ID, 1),
354 "README.md",
355 ),
354356 "v2\n",
355357 );
356358 assert.equal(
357 gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2), "README.md"),
359 gitShowFileAtRef(
360 harness.workspaceDir,
361 checkpointRefForThreadTurn(THREAD_ID, 2),
362 "README.md",
363 ),
358364 "v3\n",
359365 );
360366 }),
......@@ -416,7 +422,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons
416422
417423 yield* harness.engine.dispatch({
418424 type: "thread.approval.respond",
419 commandId: asCommandId("cmd-approval-respond"),
425 commandId: CommandId.makeUnsafe("cmd-approval-respond"),
420426 threadId: THREAD_ID,
421427 requestId: APPROVAL_REQUEST_ID,
422428 decision: "accept",
......@@ -507,7 +513,10 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
507513 if (Option.isSome(checkpointRow)) {
508514 assert.equal(checkpointRow.value.status, "error");
509515 }
510 assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1)), true);
516 assert.equal(
517 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 1)),
518 true,
519 );
511520 }),
512521 ),
513522);
......@@ -644,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
644653
645654 yield* harness.engine.dispatch({
646655 type: "thread.checkpoint.revert",
647 commandId: asCommandId("cmd-checkpoint-revert"),
656 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"),
648657 threadId: THREAD_ID,
649658 turnCount: 1,
650659 createdAt: nowIso(),
......@@ -681,7 +690,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
681690 true,
682691 );
683692 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");
684 assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false);
693 assert.equal(
694 gitRefExists(harness.workspaceDir, checkpointRefForThreadTurn(THREAD_ID, 2)),
695 false,
696 );
685697 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
686698
687699 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
......@@ -701,7 +713,7 @@ it.live(
701713
702714 yield* harness.engine.dispatch({
703715 type: "thread.checkpoint.revert",
704 commandId: asCommandId("cmd-checkpoint-revert-no-session"),
716 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"),
705717 threadId: THREAD_ID,
706718 turnCount: 0,
707719 createdAt: nowIso(),
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts created+190
......@@ -0,0 +1,190 @@
1import {
2 CheckpointRef,
3 ProjectId,
4 ThreadId,
5 TurnId,
6 type OrchestrationReadModel,
7} from "@t3tools/contracts";
8import { Effect, Layer } from "effect";
9import { describe, expect, it } from "vitest";
10
11import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
12import { checkpointRefForThreadTurn } from "../Refs.ts";
13import { CheckpointDiffQueryLive } from "./CheckpointDiffQuery.ts";
14import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
15import { CheckpointDiffQuery } from "../Services/CheckpointDiffQuery.ts";
16
17const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
18const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
19const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
20
21function makeSnapshot(input: {
22 readonly projectId: ProjectId;
23 readonly threadId: ThreadId;
24 readonly workspaceRoot: string;
25 readonly worktreePath: string | null;
26 readonly checkpointTurnCount: number;
27 readonly checkpointRef: CheckpointRef;
28}): OrchestrationReadModel {
29 return {
30 snapshotSequence: 0,
31 updatedAt: "2026-01-01T00:00:00.000Z",
32 projects: [
33 {
34 id: input.projectId,
35 title: "Project",
36 workspaceRoot: input.workspaceRoot,
37 defaultModel: null,
38 scripts: [],
39 createdAt: "2026-01-01T00:00:00.000Z",
40 updatedAt: "2026-01-01T00:00:00.000Z",
41 deletedAt: null,
42 },
43 ],
44 threads: [
45 {
46 id: input.threadId,
47 projectId: input.projectId,
48 title: "Thread",
49 model: "gpt-5-codex",
50 branch: null,
51 worktreePath: input.worktreePath,
52 latestTurnId: asTurnId("turn-1"),
53 createdAt: "2026-01-01T00:00:00.000Z",
54 updatedAt: "2026-01-01T00:00:00.000Z",
55 deletedAt: null,
56 messages: [],
57 activities: [],
58 checkpoints: [
59 {
60 turnId: asTurnId("turn-1"),
61 checkpointTurnCount: input.checkpointTurnCount,
62 checkpointRef: input.checkpointRef,
63 status: "ready",
64 files: [],
65 assistantMessageId: null,
66 completedAt: "2026-01-01T00:00:00.000Z",
67 },
68 ],
69 session: null,
70 },
71 ],
72 };
73}
74
75describe("CheckpointDiffQueryLive", () => {
76 it("computes diffs using canonical turn-0 checkpoint refs", async () => {
77 const projectId = asProjectId("project-1");
78 const threadId = ThreadId.makeUnsafe("thread-1");
79 const toCheckpointRef = asCheckpointRef("refs/t3/checkpoints/thread-1/turn/1");
80 const hasCheckpointRefCalls: Array<CheckpointRef> = [];
81 const diffCheckpointsCalls: Array<{
82 readonly fromCheckpointRef: CheckpointRef;
83 readonly toCheckpointRef: CheckpointRef;
84 readonly cwd: string;
85 }> = [];
86
87 const snapshot = makeSnapshot({
88 projectId,
89 threadId,
90 workspaceRoot: "/tmp/workspace",
91 worktreePath: null,
92 checkpointTurnCount: 1,
93 checkpointRef: toCheckpointRef,
94 });
95
96 const checkpointStore: CheckpointStoreShape = {
97 isGitRepository: () => Effect.succeed(true),
98 captureCheckpoint: () => Effect.void,
99 hasCheckpointRef: ({ checkpointRef }) =>
100 Effect.sync(() => {
101 hasCheckpointRefCalls.push(checkpointRef);
102 return true;
103 }),
104 restoreCheckpoint: () => Effect.succeed(true),
105 diffCheckpoints: ({ fromCheckpointRef, toCheckpointRef, cwd }) =>
106 Effect.sync(() => {
107 diffCheckpointsCalls.push({ fromCheckpointRef, toCheckpointRef, cwd });
108 return "diff patch";
109 }),
110 deleteCheckpointRefs: () => Effect.void,
111 };
112
113 const layer = CheckpointDiffQueryLive.pipe(
114 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
115 Layer.provideMerge(
116 Layer.succeed(ProjectionSnapshotQuery, {
117 getSnapshot: () => Effect.succeed(snapshot),
118 }),
119 ),
120 );
121
122 const result = await Effect.runPromise(
123 Effect.gen(function* () {
124 const query = yield* CheckpointDiffQuery;
125 return yield* query.getTurnDiff({
126 threadId,
127 fromTurnCount: 0,
128 toTurnCount: 1,
129 });
130 }).pipe(Effect.provide(layer)),
131 );
132
133 const expectedFromRef = checkpointRefForThreadTurn(threadId, 0);
134 expect(hasCheckpointRefCalls).toEqual([expectedFromRef, toCheckpointRef]);
135 expect(diffCheckpointsCalls).toEqual([
136 {
137 cwd: "/tmp/workspace",
138 fromCheckpointRef: expectedFromRef,
139 toCheckpointRef,
140 },
141 ]);
142 expect(result).toEqual({
143 threadId,
144 fromTurnCount: 0,
145 toTurnCount: 1,
146 diff: "diff patch",
147 });
148 });
149
150 it("fails when the thread is missing from the snapshot", async () => {
151 const threadId = ThreadId.makeUnsafe("thread-missing");
152
153 const checkpointStore: CheckpointStoreShape = {
154 isGitRepository: () => Effect.succeed(true),
155 captureCheckpoint: () => Effect.void,
156 hasCheckpointRef: () => Effect.succeed(true),
157 restoreCheckpoint: () => Effect.succeed(true),
158 diffCheckpoints: () => Effect.succeed(""),
159 deleteCheckpointRefs: () => Effect.void,
160 };
161
162 const layer = CheckpointDiffQueryLive.pipe(
163 Layer.provideMerge(Layer.succeed(CheckpointStore, checkpointStore)),
164 Layer.provideMerge(
165 Layer.succeed(ProjectionSnapshotQuery, {
166 getSnapshot: () =>
167 Effect.succeed({
168 snapshotSequence: 0,
169 projects: [],
170 threads: [],
171 updatedAt: "2026-01-01T00:00:00.000Z",
172 } satisfies OrchestrationReadModel),
173 }),
174 ),
175 );
176
177 await expect(
178 Effect.runPromise(
179 Effect.gen(function* () {
180 const query = yield* CheckpointDiffQuery;
181 return yield* query.getTurnDiff({
182 threadId,
183 fromTurnCount: 0,
184 toTurnCount: 1,
185 });
186 }).pipe(Effect.provide(layer)),
187 ),
188 ).rejects.toThrow("Thread 'thread-missing' not found.");
189 });
190});
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.ts created+193
......@@ -0,0 +1,193 @@
1import {
2 OrchestrationGetTurnDiffResult,
3 type ProjectId,
4 type OrchestrationGetFullThreadDiffInput,
5 type OrchestrationGetFullThreadDiffResult,
6 type OrchestrationGetTurnDiffResult as OrchestrationGetTurnDiffResultType,
7} from "@t3tools/contracts";
8import { Effect, Layer, Schema } from "effect";
9
10import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
11import { CheckpointInvariantError, CheckpointUnavailableError } from "../Errors.ts";
12import { checkpointRefForThreadTurn } from "../Refs.ts";
13import { CheckpointStore } from "../Services/CheckpointStore.ts";
14import { CheckpointDiffQuery, type CheckpointDiffQueryShape } from "../Services/CheckpointDiffQuery.ts";
15
16function trimToNonEmpty(value: string | null | undefined): string | undefined {
17 const normalized = value?.trim();
18 return normalized && normalized.length > 0 ? normalized : undefined;
19}
20
21function resolveThreadWorkspaceCwd(input: {
22 readonly thread: {
23 readonly projectId: ProjectId;
24 readonly worktreePath: string | null;
25 };
26 readonly projects: ReadonlyArray<{
27 readonly id: ProjectId;
28 readonly workspaceRoot: string;
29 }>;
30}): string | undefined {
31 const worktreeCwd = trimToNonEmpty(input.thread.worktreePath);
32 if (worktreeCwd) {
33 return worktreeCwd;
34 }
35
36 const workspaceRoot = input.projects.find(
37 (project) => project.id === input.thread.projectId,
38 )?.workspaceRoot;
39 return trimToNonEmpty(workspaceRoot);
40}
41
42const isTurnDiffResult = Schema.is(OrchestrationGetTurnDiffResult);
43
44const make = Effect.gen(function* () {
45 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
46 const checkpointStore = yield* CheckpointStore;
47
48 const getTurnDiff: CheckpointDiffQueryShape["getTurnDiff"] = (input) =>
49 Effect.gen(function* () {
50 const operation = "CheckpointDiffQuery.getTurnDiff";
51
52 if (input.fromTurnCount === input.toTurnCount) {
53 const emptyDiff: OrchestrationGetTurnDiffResultType = {
54 threadId: input.threadId,
55 fromTurnCount: input.fromTurnCount,
56 toTurnCount: input.toTurnCount,
57 diff: "",
58 };
59 if (!isTurnDiffResult(emptyDiff)) {
60 return yield* new CheckpointInvariantError({
61 operation,
62 detail: "Computed turn diff result does not satisfy contract schema.",
63 });
64 }
65 return emptyDiff;
66 }
67
68 const snapshot = yield* projectionSnapshotQuery.getSnapshot();
69 const thread = snapshot.threads.find((entry) => entry.id === input.threadId);
70 if (!thread) {
71 return yield* new CheckpointInvariantError({
72 operation,
73 detail: `Thread '${input.threadId}' not found.`,
74 });
75 }
76
77 const maxTurnCount = thread.checkpoints.reduce(
78 (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount),
79 0,
80 );
81 if (input.toTurnCount > maxTurnCount) {
82 return yield* new CheckpointUnavailableError({
83 threadId: input.threadId,
84 turnCount: input.toTurnCount,
85 detail: `Turn diff range exceeds current turn count: requested ${input.toTurnCount}, current ${maxTurnCount}.`,
86 });
87 }
88
89 const workspaceCwd = resolveThreadWorkspaceCwd({
90 thread,
91 projects: snapshot.projects,
92 });
93 if (!workspaceCwd) {
94 return yield* new CheckpointInvariantError({
95 operation,
96 detail: `Workspace path missing for thread '${input.threadId}' when computing turn diff.`,
97 });
98 }
99
100 const fromCheckpointRef =
101 input.fromTurnCount === 0
102 ? checkpointRefForThreadTurn(input.threadId, 0)
103 : thread.checkpoints.find(
104 (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount,
105 )?.checkpointRef;
106 if (!fromCheckpointRef) {
107 return yield* new CheckpointUnavailableError({
108 threadId: input.threadId,
109 turnCount: input.fromTurnCount,
110 detail: `Checkpoint ref is unavailable for turn ${input.fromTurnCount}.`,
111 });
112 }
113
114 const toCheckpointRef = thread.checkpoints.find(
115 (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount,
116 )?.checkpointRef;
117 if (!toCheckpointRef) {
118 return yield* new CheckpointUnavailableError({
119 threadId: input.threadId,
120 turnCount: input.toTurnCount,
121 detail: `Checkpoint ref is unavailable for turn ${input.toTurnCount}.`,
122 });
123 }
124
125 const [fromExists, toExists] = yield* Effect.all(
126 [
127 checkpointStore.hasCheckpointRef({
128 cwd: workspaceCwd,
129 checkpointRef: fromCheckpointRef,
130 }),
131 checkpointStore.hasCheckpointRef({
132 cwd: workspaceCwd,
133 checkpointRef: toCheckpointRef,
134 }),
135 ],
136 { concurrency: "unbounded" },
137 );
138
139 if (!fromExists) {
140 return yield* new CheckpointUnavailableError({
141 threadId: input.threadId,
142 turnCount: input.fromTurnCount,
143 detail: `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount}.`,
144 });
145 }
146
147 if (!toExists) {
148 return yield* new CheckpointUnavailableError({
149 threadId: input.threadId,
150 turnCount: input.toTurnCount,
151 detail: `Filesystem checkpoint is unavailable for turn ${input.toTurnCount}.`,
152 });
153 }
154
155 const diff = yield* checkpointStore.diffCheckpoints({
156 cwd: workspaceCwd,
157 fromCheckpointRef,
158 toCheckpointRef,
159 fallbackFromToHead: false,
160 });
161
162 const turnDiff: OrchestrationGetTurnDiffResultType = {
163 threadId: input.threadId,
164 fromTurnCount: input.fromTurnCount,
165 toTurnCount: input.toTurnCount,
166 diff,
167 };
168 if (!isTurnDiffResult(turnDiff)) {
169 return yield* new CheckpointInvariantError({
170 operation,
171 detail: "Computed turn diff result does not satisfy contract schema.",
172 });
173 }
174
175 return turnDiff;
176 });
177
178 const getFullThreadDiff: CheckpointDiffQueryShape["getFullThreadDiff"] = (
179 input: OrchestrationGetFullThreadDiffInput,
180 ) =>
181 getTurnDiff({
182 threadId: input.threadId,
183 fromTurnCount: 0,
184 toTurnCount: input.toTurnCount,
185 }).pipe(Effect.map((result): OrchestrationGetFullThreadDiffResult => result));
186
187 return {
188 getTurnDiff,
189 getFullThreadDiff,
190 } satisfies CheckpointDiffQueryShape;
191});
192
193export const CheckpointDiffQueryLive = Layer.effect(CheckpointDiffQuery, make);
apps/server/src/checkpointing/Refs.ts created+10
......@@ -0,0 +1,10 @@
1import { Encoding } from "effect";
2import { CheckpointRef, type ThreadId } from "@t3tools/contracts";
3
4export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
5
6export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
7 return CheckpointRef.makeUnsafe(
8 `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`,
9 );
10}
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts created+24
......@@ -0,0 +1,24 @@
1import type {
2 OrchestrationGetFullThreadDiffInput,
3 OrchestrationGetFullThreadDiffResult,
4 OrchestrationGetTurnDiffInput,
5 OrchestrationGetTurnDiffResult,
6} from "@t3tools/contracts";
7import { ServiceMap } from "effect";
8import type { Effect } from "effect";
9
10import type { CheckpointServiceError } from "../Errors.ts";
11
12export interface CheckpointDiffQueryShape {
13 readonly getTurnDiff: (
14 input: OrchestrationGetTurnDiffInput,
15 ) => Effect.Effect<OrchestrationGetTurnDiffResult, CheckpointServiceError>;
16 readonly getFullThreadDiff: (
17 input: OrchestrationGetFullThreadDiffInput,
18 ) => Effect.Effect<OrchestrationGetFullThreadDiffResult, CheckpointServiceError>;
19}
20
21export class CheckpointDiffQuery extends ServiceMap.Service<
22 CheckpointDiffQuery,
23 CheckpointDiffQueryShape
24>()("checkpointing/CheckpointDiffQuery") {}
apps/server/src/git.ts+4-4
......@@ -216,7 +216,7 @@ export class GitCoreService {
216216
217217 async status(input: GitStatusInput): Promise<GitStatusResult> {
218218 const details = await this.statusDetails(input.cwd);
219 return GitStatusResult.makeUnsafe({
219 return {
220220 branch: details.branch,
221221 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
222222 workingTree: details.workingTree,
......@@ -224,7 +224,7 @@ export class GitCoreService {
224224 aheadCount: details.aheadCount,
225225 behindCount: details.behindCount,
226226 openPr: null,
227 });
227 };
228228 }
229229
230230 async statusDetails(cwd: string): Promise<GitStatusDetails> {
......@@ -385,11 +385,11 @@ export class GitCoreService {
385385 });
386386 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));
387387 const refreshed = await this.statusDetails(cwd);
388 return GitPullResult.makeUnsafe({
388 return {
389389 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
390390 branch,
391391 upstreamBranch: refreshed.upstreamRef,
392 });
392 };
393393 }
394394
395395 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {
apps/server/src/keybindings.test.ts+1-1
......@@ -40,7 +40,7 @@ const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRu
4040 Effect.gen(function* () {
4141 const fileSystem = yield* FileSystem.FileSystem;
4242 const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(
43 rules.map((rule) => KeybindingRule.makeUnsafe(rule)),
43 rules,
4444 );
4545 yield* fileSystem.writeFileString(configPath, encoded);
4646 });
apps/server/src/keybindings.ts+2-2
......@@ -313,7 +313,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
313313 title: "Invalid keybinding rule",
314314 }),
315315 ),
316 Effect.map((resolved) => ResolvedKeybindingRule.makeUnsafe(resolved)),
316 Effect.map((resolved) => resolved),
317317 ),
318318
319319 encode: (resolved) =>
......@@ -456,7 +456,7 @@ const makeKeybindings = Effect.gen(function* () {
456456 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
457457
458458 return Schema.encodeEffect(KeybindingsConfigJson)(
459 rules.map((rule) => KeybindingRule.makeUnsafe(rule)),
459 rules,
460460 ).pipe(
461461 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
462462 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),
apps/server/src/orchestration/Layer.test.ts+33-35
......@@ -22,9 +22,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
2222import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
2323import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
2424
25const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
2625const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
27const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
2826const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
2927const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3028const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
......@@ -58,7 +56,7 @@ describe("OrchestrationEngine", () => {
5856 await system.run(
5957 engine.dispatch({
6058 type: "project.create",
61 commandId: asCommandId("cmd-project-1-create"),
59 commandId: CommandId.makeUnsafe("cmd-project-1-create"),
6260 projectId: asProjectId("project-1"),
6361 title: "Project 1",
6462 workspaceRoot: "/tmp/project-1",
......@@ -69,8 +67,8 @@ describe("OrchestrationEngine", () => {
6967 await system.run(
7068 engine.dispatch({
7169 type: "thread.create",
72 commandId: asCommandId("cmd-thread-1-create"),
73 threadId: asThreadId("thread-1"),
70 commandId: CommandId.makeUnsafe("cmd-thread-1-create"),
71 threadId: ThreadId.makeUnsafe("thread-1"),
7472 projectId: asProjectId("project-1"),
7573 title: "Thread",
7674 model: "gpt-5-codex",
......@@ -82,8 +80,8 @@ describe("OrchestrationEngine", () => {
8280 await system.run(
8381 engine.dispatch({
8482 type: "thread.turn.start",
85 commandId: asCommandId("cmd-turn-start-1"),
86 threadId: asThreadId("thread-1"),
83 commandId: CommandId.makeUnsafe("cmd-turn-start-1"),
84 threadId: ThreadId.makeUnsafe("thread-1"),
8785 message: {
8886 messageId: asMessageId("msg-1"),
8987 role: "user",
......@@ -108,7 +106,7 @@ describe("OrchestrationEngine", () => {
108106 await system.run(
109107 engine.dispatch({
110108 type: "project.create",
111 commandId: asCommandId("cmd-project-replay-create"),
109 commandId: CommandId.makeUnsafe("cmd-project-replay-create"),
112110 projectId: asProjectId("project-replay"),
113111 title: "Replay Project",
114112 workspaceRoot: "/tmp/project-replay",
......@@ -119,8 +117,8 @@ describe("OrchestrationEngine", () => {
119117 await system.run(
120118 engine.dispatch({
121119 type: "thread.create",
122 commandId: asCommandId("cmd-thread-replay-create"),
123 threadId: asThreadId("thread-replay"),
120 commandId: CommandId.makeUnsafe("cmd-thread-replay-create"),
121 threadId: ThreadId.makeUnsafe("thread-replay"),
124122 projectId: asProjectId("project-replay"),
125123 title: "replay",
126124 model: "gpt-5-codex",
......@@ -132,8 +130,8 @@ describe("OrchestrationEngine", () => {
132130 await system.run(
133131 engine.dispatch({
134132 type: "thread.delete",
135 commandId: asCommandId("cmd-thread-replay-delete"),
136 threadId: asThreadId("thread-replay"),
133 commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"),
134 threadId: ThreadId.makeUnsafe("thread-replay"),
137135 }),
138136 );
139137
......@@ -158,7 +156,7 @@ describe("OrchestrationEngine", () => {
158156 await system.run(
159157 engine.dispatch({
160158 type: "project.create",
161 commandId: asCommandId("cmd-project-stream-create"),
159 commandId: CommandId.makeUnsafe("cmd-project-stream-create"),
162160 projectId: asProjectId("project-stream"),
163161 title: "Stream Project",
164162 workspaceRoot: "/tmp/project-stream",
......@@ -179,8 +177,8 @@ describe("OrchestrationEngine", () => {
179177 yield* Effect.sleep("10 millis");
180178 yield* engine.dispatch({
181179 type: "thread.create",
182 commandId: asCommandId("cmd-stream-thread-create"),
183 threadId: asThreadId("thread-stream"),
180 commandId: CommandId.makeUnsafe("cmd-stream-thread-create"),
181 threadId: ThreadId.makeUnsafe("thread-stream"),
184182 projectId: asProjectId("project-stream"),
185183 title: "domain-stream",
186184 model: "gpt-5-codex",
......@@ -190,8 +188,8 @@ describe("OrchestrationEngine", () => {
190188 });
191189 yield* engine.dispatch({
192190 type: "thread.meta.update",
193 commandId: asCommandId("cmd-stream-thread-update"),
194 threadId: asThreadId("thread-stream"),
191 commandId: CommandId.makeUnsafe("cmd-stream-thread-update"),
192 threadId: ThreadId.makeUnsafe("thread-stream"),
195193 title: "domain-stream-updated",
196194 });
197195 eventTypes.push((yield* Queue.take(eventQueue)).type);
......@@ -211,7 +209,7 @@ describe("OrchestrationEngine", () => {
211209 await system.run(
212210 engine.dispatch({
213211 type: "project.create",
214 commandId: asCommandId("cmd-project-turn-diff-create"),
212 commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"),
215213 projectId: asProjectId("project-turn-diff"),
216214 title: "Turn Diff Project",
217215 workspaceRoot: "/tmp/project-turn-diff",
......@@ -222,8 +220,8 @@ describe("OrchestrationEngine", () => {
222220 await system.run(
223221 engine.dispatch({
224222 type: "thread.create",
225 commandId: asCommandId("cmd-thread-turn-diff-create"),
226 threadId: asThreadId("thread-turn-diff"),
223 commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"),
224 threadId: ThreadId.makeUnsafe("thread-turn-diff"),
227225 projectId: asProjectId("project-turn-diff"),
228226 title: "Turn diff thread",
229227 model: "gpt-5-codex",
......@@ -235,8 +233,8 @@ describe("OrchestrationEngine", () => {
235233 await system.run(
236234 engine.dispatch({
237235 type: "thread.turn.diff.complete",
238 commandId: asCommandId("cmd-turn-diff-complete"),
239 threadId: asThreadId("thread-turn-diff"),
236 commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"),
237 threadId: ThreadId.makeUnsafe("thread-turn-diff"),
240238 turnId: asTurnId("turn-1"),
241239 completedAt: createdAt,
242240 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
......@@ -275,7 +273,7 @@ describe("OrchestrationEngine", () => {
275273
276274 const flakyStore: OrchestrationEventStoreShape = {
277275 append(event) {
278 if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) {
276 if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) {
279277 shouldFailFirstAppend = false;
280278 return Effect.fail(
281279 new PersistenceSqlError({
......@@ -314,7 +312,7 @@ describe("OrchestrationEngine", () => {
314312 await runtime.runPromise(
315313 engine.dispatch({
316314 type: "project.create",
317 commandId: asCommandId("cmd-project-flaky-create"),
315 commandId: CommandId.makeUnsafe("cmd-project-flaky-create"),
318316 projectId: asProjectId("project-flaky"),
319317 title: "Flaky Project",
320318 workspaceRoot: "/tmp/project-flaky",
......@@ -327,8 +325,8 @@ describe("OrchestrationEngine", () => {
327325 runtime.runPromise(
328326 engine.dispatch({
329327 type: "thread.create",
330 commandId: asCommandId("cmd-flaky-1"),
331 threadId: asThreadId("thread-flaky-fail"),
328 commandId: CommandId.makeUnsafe("cmd-flaky-1"),
329 threadId: ThreadId.makeUnsafe("thread-flaky-fail"),
332330 projectId: asProjectId("project-flaky"),
333331 title: "flaky-fail",
334332 model: "gpt-5-codex",
......@@ -342,8 +340,8 @@ describe("OrchestrationEngine", () => {
342340 const result = await runtime.runPromise(
343341 engine.dispatch({
344342 type: "thread.create",
345 commandId: asCommandId("cmd-flaky-2"),
346 threadId: asThreadId("thread-flaky-ok"),
343 commandId: CommandId.makeUnsafe("cmd-flaky-2"),
344 threadId: ThreadId.makeUnsafe("thread-flaky-ok"),
347345 projectId: asProjectId("project-flaky"),
348346 title: "flaky-ok",
349347 model: "gpt-5-codex",
......@@ -366,8 +364,8 @@ describe("OrchestrationEngine", () => {
366364 system.run(
367365 engine.dispatch({
368366 type: "thread.turn.start",
369 commandId: asCommandId("cmd-invariant-missing-thread"),
370 threadId: asThreadId("thread-missing"),
367 commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"),
368 threadId: ThreadId.makeUnsafe("thread-missing"),
371369 message: {
372370 messageId: asMessageId("msg-missing"),
373371 role: "user",
......@@ -390,7 +388,7 @@ describe("OrchestrationEngine", () => {
390388 await system.run(
391389 engine.dispatch({
392390 type: "project.create",
393 commandId: asCommandId("cmd-project-duplicate-create"),
391 commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"),
394392 projectId: asProjectId("project-duplicate"),
395393 title: "Duplicate Project",
396394 workspaceRoot: "/tmp/project-duplicate",
......@@ -402,8 +400,8 @@ describe("OrchestrationEngine", () => {
402400 await system.run(
403401 engine.dispatch({
404402 type: "thread.create",
405 commandId: asCommandId("cmd-thread-duplicate-1"),
406 threadId: asThreadId("thread-duplicate"),
403 commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"),
404 threadId: ThreadId.makeUnsafe("thread-duplicate"),
407405 projectId: asProjectId("project-duplicate"),
408406 title: "duplicate",
409407 model: "gpt-5-codex",
......@@ -417,8 +415,8 @@ describe("OrchestrationEngine", () => {
417415 system.run(
418416 engine.dispatch({
419417 type: "thread.create",
420 commandId: asCommandId("cmd-thread-duplicate-2"),
421 threadId: asThreadId("thread-duplicate"),
418 commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"),
419 threadId: ThreadId.makeUnsafe("thread-duplicate"),
422420 projectId: asProjectId("project-duplicate"),
423421 title: "duplicate",
424422 model: "gpt-5-codex",
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+66-49
......@@ -5,7 +5,6 @@ import { execFileSync } from "node:child_process";
55
66import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
77import {
8 CheckpointRef,
98 CommandId,
109 EventId,
1110 MessageId,
......@@ -36,19 +35,13 @@ import {
3635 ProviderService,
3736 type ProviderServiceShape,
3837} from "../../provider/Services/ProviderService.ts";
38import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts";
3939
40const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
4140const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
42const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
4341const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
4442const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
4543const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4644
47function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
48 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
49 return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`);
50}
51
5245function createProviderServiceHarness(cwd: string, hasSession = true) {
5346 const now = new Date().toISOString();
5447 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
......@@ -109,7 +102,7 @@ async function waitForThread(
109102 activities: ReadonlyArray<{ kind: string }>;
110103 }> => {
111104 const readModel = await Effect.runPromise(engine.getReadModel());
112 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
105 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
113106 if (thread && predicate(thread)) {
114107 return thread;
115108 }
......@@ -250,7 +243,7 @@ describe("CheckpointReactor", () => {
250243 await Effect.runPromise(
251244 engine.dispatch({
252245 type: "project.create",
253 commandId: asCommandId("cmd-project-create"),
246 commandId: CommandId.makeUnsafe("cmd-project-create"),
254247 projectId: asProjectId("project-1"),
255248 title: "Test Project",
256249 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
......@@ -261,8 +254,8 @@ describe("CheckpointReactor", () => {
261254 await Effect.runPromise(
262255 engine.dispatch({
263256 type: "thread.create",
264 commandId: asCommandId("cmd-thread-create"),
265 threadId: asThreadId("thread-1"),
257 commandId: CommandId.makeUnsafe("cmd-thread-create"),
258 threadId: ThreadId.makeUnsafe("thread-1"),
266259 projectId: asProjectId("project-1"),
267260 title: "Thread",
268261 model: "gpt-5-codex",
......@@ -276,21 +269,21 @@ describe("CheckpointReactor", () => {
276269 await runtime.runPromise(
277270 checkpointStore.captureCheckpoint({
278271 cwd,
279 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 0),
272 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
280273 }),
281274 );
282275 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
283276 await runtime.runPromise(
284277 checkpointStore.captureCheckpoint({
285278 cwd,
286 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
279 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
287280 }),
288281 );
289282 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
290283 await runtime.runPromise(
291284 checkpointStore.captureCheckpoint({
292285 cwd,
293 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2),
286 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
294287 }),
295288 );
296289 }
......@@ -309,10 +302,10 @@ describe("CheckpointReactor", () => {
309302 await Effect.runPromise(
310303 harness.engine.dispatch({
311304 type: "thread.session.set",
312 commandId: asCommandId("cmd-session-set-capture"),
313 threadId: asThreadId("thread-1"),
305 commandId: CommandId.makeUnsafe("cmd-session-set-capture"),
306 threadId: ThreadId.makeUnsafe("thread-1"),
314307 session: {
315 threadId: asThreadId("thread-1"),
308 threadId: ThreadId.makeUnsafe("thread-1"),
316309 status: "ready",
317310 providerName: "codex",
318311 providerSessionId: asSessionId("sess-1"),
......@@ -334,7 +327,7 @@ describe("CheckpointReactor", () => {
334327 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
335328 turnId: asTurnId("turn-1"),
336329 });
337 await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0));
330 await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0));
338331
339332 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
340333 harness.provider.emit({
......@@ -354,13 +347,25 @@ describe("CheckpointReactor", () => {
354347 (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1,
355348 );
356349 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
357 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0))).toBe(true);
358 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true);
350 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0))).toBe(
351 true,
352 );
353 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe(
354 true,
355 );
359356 expect(
360 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"),
357 gitShowFileAtRef(
358 harness.cwd,
359 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
360 "README.md",
361 ),
361362 ).toBe("v1\n");
362363 expect(
363 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"),
364 gitShowFileAtRef(
365 harness.cwd,
366 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
367 "README.md",
368 ),
364369 ).toBe("v2\n");
365370 });
366371
......@@ -374,8 +379,8 @@ describe("CheckpointReactor", () => {
374379 await Effect.runPromise(
375380 harness.engine.dispatch({
376381 type: "thread.turn.start",
377 commandId: asCommandId("cmd-turn-start-for-baseline"),
378 threadId: asThreadId("thread-1"),
382 commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"),
383 threadId: ThreadId.makeUnsafe("thread-1"),
379384 message: {
380385 messageId: MessageId.makeUnsafe("message-user-1"),
381386 role: "user",
......@@ -386,9 +391,13 @@ describe("CheckpointReactor", () => {
386391 }),
387392 );
388393
389 await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0));
394 await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0));
390395 expect(
391 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0), "README.md"),
396 gitShowFileAtRef(
397 harness.cwd,
398 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
399 "README.md",
400 ),
392401 ).toBe("v1\n");
393402 });
394403
......@@ -403,10 +412,10 @@ describe("CheckpointReactor", () => {
403412 await Effect.runPromise(
404413 harness.engine.dispatch({
405414 type: "thread.session.set",
406 commandId: asCommandId("cmd-session-set-missing-provider-cwd"),
407 threadId: asThreadId("thread-1"),
415 commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"),
416 threadId: ThreadId.makeUnsafe("thread-1"),
408417 session: {
409 threadId: asThreadId("thread-1"),
418 threadId: ThreadId.makeUnsafe("thread-1"),
410419 status: "running",
411420 providerName: "codex",
412421 providerSessionId: asSessionId("sess-missing"),
......@@ -432,9 +441,15 @@ describe("CheckpointReactor", () => {
432441 });
433442
434443 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
435 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true);
444 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe(
445 true,
446 );
436447 expect(
437 gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"),
448 gitShowFileAtRef(
449 harness.cwd,
450 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
451 "README.md",
452 ),
438453 ).toBe("v2\n");
439454 });
440455
......@@ -445,10 +460,10 @@ describe("CheckpointReactor", () => {
445460 await Effect.runPromise(
446461 harness.engine.dispatch({
447462 type: "thread.session.set",
448 commandId: asCommandId("cmd-session-set-checkpoint-captured"),
449 threadId: asThreadId("thread-1"),
463 commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"),
464 threadId: ThreadId.makeUnsafe("thread-1"),
450465 session: {
451 threadId: asThreadId("thread-1"),
466 threadId: ThreadId.makeUnsafe("thread-1"),
452467 status: "ready",
453468 providerName: "codex",
454469 providerSessionId: asSessionId("sess-1"),
......@@ -488,10 +503,10 @@ describe("CheckpointReactor", () => {
488503 await Effect.runPromise(
489504 harness.engine.dispatch({
490505 type: "thread.session.set",
491 commandId: asCommandId("cmd-session-set"),
492 threadId: asThreadId("thread-1"),
506 commandId: CommandId.makeUnsafe("cmd-session-set"),
507 threadId: ThreadId.makeUnsafe("thread-1"),
493508 session: {
494 threadId: asThreadId("thread-1"),
509 threadId: ThreadId.makeUnsafe("thread-1"),
495510 status: "ready",
496511 providerName: "codex",
497512 providerSessionId: asSessionId("sess-1"),
......@@ -507,11 +522,11 @@ describe("CheckpointReactor", () => {
507522 await Effect.runPromise(
508523 harness.engine.dispatch({
509524 type: "thread.turn.diff.complete",
510 commandId: asCommandId("cmd-diff-1"),
511 threadId: asThreadId("thread-1"),
525 commandId: CommandId.makeUnsafe("cmd-diff-1"),
526 threadId: ThreadId.makeUnsafe("thread-1"),
512527 turnId: asTurnId("turn-1"),
513528 completedAt: createdAt,
514 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
529 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
515530 status: "ready",
516531 files: [],
517532 checkpointTurnCount: 1,
......@@ -521,11 +536,11 @@ describe("CheckpointReactor", () => {
521536 await Effect.runPromise(
522537 harness.engine.dispatch({
523538 type: "thread.turn.diff.complete",
524 commandId: asCommandId("cmd-diff-2"),
525 threadId: asThreadId("thread-1"),
539 commandId: CommandId.makeUnsafe("cmd-diff-2"),
540 threadId: ThreadId.makeUnsafe("thread-1"),
526541 turnId: asTurnId("turn-2"),
527542 completedAt: createdAt,
528 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2),
543 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
529544 status: "ready",
530545 files: [],
531546 checkpointTurnCount: 2,
......@@ -536,8 +551,8 @@ describe("CheckpointReactor", () => {
536551 await Effect.runPromise(
537552 harness.engine.dispatch({
538553 type: "thread.checkpoint.revert",
539 commandId: asCommandId("cmd-revert-request"),
540 threadId: asThreadId("thread-1"),
554 commandId: CommandId.makeUnsafe("cmd-revert-request"),
555 threadId: ThreadId.makeUnsafe("thread-1"),
541556 turnCount: 1,
542557 createdAt,
543558 }),
......@@ -555,7 +570,9 @@ describe("CheckpointReactor", () => {
555570 numTurns: 1,
556571 });
557572 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
558 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 2))).toBe(false);
573 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2))).toBe(
574 false,
575 );
559576 });
560577
561578 it("appends an error activity when revert is requested without an active session", async () => {
......@@ -565,8 +582,8 @@ describe("CheckpointReactor", () => {
565582 await Effect.runPromise(
566583 harness.engine.dispatch({
567584 type: "thread.checkpoint.revert",
568 commandId: asCommandId("cmd-revert-no-session"),
569 threadId: asThreadId("thread-1"),
585 commandId: CommandId.makeUnsafe("cmd-revert-no-session"),
586 threadId: ThreadId.makeUnsafe("thread-1"),
570587 turnCount: 1,
571588 createdAt,
572589 }),
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-10
......@@ -1,17 +1,18 @@
11import {
2 CheckpointRef,
32 CommandId,
43 EventId,
54 MessageId,
65 ProviderSessionId,
76 ThreadId,
87 TurnId,
8 type ProjectId,
99 type OrchestrationEvent,
1010 type ProviderRuntimeEvent,
1111} from "@t3tools/contracts";
1212import { Effect, Layer, Option, Queue, Stream } from "effect";
1313
1414import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
15import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts";
1516import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
1617import { ProviderService } from "../../provider/Services/ProviderService.ts";
1718import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
......@@ -19,8 +20,6 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
1920import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2021import { OrchestrationDispatchError } from "../Errors.ts";
2122
22const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
23
2423type ReactorInput =
2524 | {
2625 readonly source: "runtime";
......@@ -31,11 +30,6 @@ type ReactorInput =
3130 readonly event: OrchestrationEvent;
3231 };
3332
34function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
35 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
36 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
37}
38
3933function toTurnId(value: string | undefined): TurnId | null {
4034 const normalized = value?.trim();
4135 return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null;
......@@ -48,11 +42,11 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined {
4842
4943function resolveThreadWorkspaceCwd(input: {
5044 readonly thread: {
51 readonly projectId: string;
45 readonly projectId: ProjectId;
5246 readonly worktreePath: string | null;
5347 };
5448 readonly projects: ReadonlyArray<{
55 readonly id: string;
49 readonly id: ProjectId;
5650 readonly workspaceRoot: string;
5751 }>;
5852}): string | undefined {
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1-23
......@@ -5,13 +5,11 @@ import type {
55 ThreadId,
66} from "@t3tools/contracts";
77import { OrchestrationCommand } from "@t3tools/contracts";
8import { Deferred, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect";
8import { Deferred, Effect, Layer, Option, PubSub, Queue, Stream } from "effect";
99
1010import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
1111import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
1212import {
13 toOrchestrationCommandDecodeError,
14 toOrchestrationJsonParseError,
1513 OrchestrationCommandInvariantError,
1614 OrchestrationCommandPreviouslyRejectedError,
1715 type OrchestrationDispatchError,
......@@ -29,8 +27,6 @@ interface CommandEnvelope {
2927 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;
3028}
3129
32const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
33
3430function commandToAggregateRef(command: OrchestrationCommand): {
3531 readonly aggregateKind: "project" | "thread";
3632 readonly aggregateId: ProjectId | ThreadId;
......@@ -175,30 +171,12 @@ const makeOrchestrationEngine = Effect.gen(function* () {
175171 return yield* Deferred.await(result);
176172 });
177173
178 const dispatchUnknownCommand: OrchestrationEngineShape["dispatchUnknownCommand"] = (command) =>
179 Effect.gen(function* () {
180 const payload =
181 typeof command === "string"
182 ? yield* Effect.try({
183 try: () => JSON.parse(command) as unknown,
184 catch: toOrchestrationJsonParseError,
185 })
186 : command;
187
188 const decoded = yield* decodeUnknownCommand(payload).pipe(
189 Effect.mapError(toOrchestrationCommandDecodeError),
190 );
191
192 return yield* dispatch(decoded);
193 });
194
195174 const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] =
196175 Stream.fromPubSub(eventPubSub);
197176
198177 return {
199178 getReadModel,
200179 readEvents,
201 dispatchUnknownCommand,
202180 dispatch,
203181 streamDomainEvents,
204182 } satisfies OrchestrationEngineShape;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2-3
......@@ -18,7 +18,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu
1818import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
1919
2020const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
21const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
2221const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
2322const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
2423const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
......@@ -225,7 +224,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
225224 ]);
226225 assert.deepEqual(snapshot.threads, [
227226 {
228 id: asThreadId("thread-1"),
227 id: ThreadId.makeUnsafe("thread-1"),
229228 projectId: asProjectId("project-1"),
230229 title: "Thread 1",
231230 model: "gpt-5-codex",
......@@ -269,7 +268,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
269268 },
270269 ],
271270 session: {
272 threadId: asThreadId("thread-1"),
271 threadId: ThreadId.makeUnsafe("thread-1"),
273272 status: "running",
274273 providerName: "codex",
275274 providerSessionId: asProviderSessionId("provider-session-1"),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+22-24
......@@ -26,9 +26,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
2626import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2727import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
2828
29const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
3029const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
31const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
3230const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
3331const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
3432const asApprovalRequestId = (value: string): ApprovalRequestId =>
......@@ -126,7 +124,7 @@ describe("ProviderCommandReactor", () => {
126124 await Effect.runPromise(
127125 engine.dispatch({
128126 type: "project.create",
129 commandId: asCommandId("cmd-project-create"),
127 commandId: CommandId.makeUnsafe("cmd-project-create"),
130128 projectId: asProjectId("project-1"),
131129 title: "Provider Project",
132130 workspaceRoot: "/tmp/provider-project",
......@@ -137,8 +135,8 @@ describe("ProviderCommandReactor", () => {
137135 await Effect.runPromise(
138136 engine.dispatch({
139137 type: "thread.create",
140 commandId: asCommandId("cmd-thread-create"),
141 threadId: asThreadId("thread-1"),
138 commandId: CommandId.makeUnsafe("cmd-thread-create"),
139 threadId: ThreadId.makeUnsafe("thread-1"),
142140 projectId: asProjectId("project-1"),
143141 title: "Thread",
144142 model: "gpt-5-codex",
......@@ -165,8 +163,8 @@ describe("ProviderCommandReactor", () => {
165163 await Effect.runPromise(
166164 harness.engine.dispatch({
167165 type: "thread.turn.start",
168 commandId: asCommandId("cmd-turn-start-1"),
169 threadId: asThreadId("thread-1"),
166 commandId: CommandId.makeUnsafe("cmd-turn-start-1"),
167 threadId: ThreadId.makeUnsafe("thread-1"),
170168 message: {
171169 messageId: asMessageId("user-message-1"),
172170 role: "user",
......@@ -185,7 +183,7 @@ describe("ProviderCommandReactor", () => {
185183 });
186184
187185 const readModel = await Effect.runPromise(harness.engine.getReadModel());
188 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
186 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
189187 expect(thread?.session?.providerSessionId).toBe("sess-1");
190188 });
191189
......@@ -196,10 +194,10 @@ describe("ProviderCommandReactor", () => {
196194 await Effect.runPromise(
197195 harness.engine.dispatch({
198196 type: "thread.session.set",
199 commandId: asCommandId("cmd-session-set"),
200 threadId: asThreadId("thread-1"),
197 commandId: CommandId.makeUnsafe("cmd-session-set"),
198 threadId: ThreadId.makeUnsafe("thread-1"),
201199 session: {
202 threadId: asThreadId("thread-1"),
200 threadId: ThreadId.makeUnsafe("thread-1"),
203201 status: "running",
204202 providerName: "codex",
205203 providerSessionId: asSessionId("sess-1"),
......@@ -215,8 +213,8 @@ describe("ProviderCommandReactor", () => {
215213 await Effect.runPromise(
216214 harness.engine.dispatch({
217215 type: "thread.turn.interrupt",
218 commandId: asCommandId("cmd-turn-interrupt"),
219 threadId: asThreadId("thread-1"),
216 commandId: CommandId.makeUnsafe("cmd-turn-interrupt"),
217 threadId: ThreadId.makeUnsafe("thread-1"),
220218 turnId: asTurnId("turn-1"),
221219 createdAt: now,
222220 }),
......@@ -235,10 +233,10 @@ describe("ProviderCommandReactor", () => {
235233 await Effect.runPromise(
236234 harness.engine.dispatch({
237235 type: "thread.session.set",
238 commandId: asCommandId("cmd-session-set-for-approval"),
239 threadId: asThreadId("thread-1"),
236 commandId: CommandId.makeUnsafe("cmd-session-set-for-approval"),
237 threadId: ThreadId.makeUnsafe("thread-1"),
240238 session: {
241 threadId: asThreadId("thread-1"),
239 threadId: ThreadId.makeUnsafe("thread-1"),
242240 status: "running",
243241 providerName: "codex",
244242 providerSessionId: asSessionId("sess-1"),
......@@ -254,8 +252,8 @@ describe("ProviderCommandReactor", () => {
254252 await Effect.runPromise(
255253 harness.engine.dispatch({
256254 type: "thread.approval.respond",
257 commandId: asCommandId("cmd-approval-respond"),
258 threadId: asThreadId("thread-1"),
255 commandId: CommandId.makeUnsafe("cmd-approval-respond"),
256 threadId: ThreadId.makeUnsafe("thread-1"),
259257 requestId: asApprovalRequestId("approval-request-1"),
260258 decision: "accept",
261259 createdAt: now,
......@@ -277,10 +275,10 @@ describe("ProviderCommandReactor", () => {
277275 await Effect.runPromise(
278276 harness.engine.dispatch({
279277 type: "thread.session.set",
280 commandId: asCommandId("cmd-session-set-for-stop"),
281 threadId: asThreadId("thread-1"),
278 commandId: CommandId.makeUnsafe("cmd-session-set-for-stop"),
279 threadId: ThreadId.makeUnsafe("thread-1"),
282280 session: {
283 threadId: asThreadId("thread-1"),
281 threadId: ThreadId.makeUnsafe("thread-1"),
284282 status: "ready",
285283 providerName: "codex",
286284 providerSessionId: asSessionId("sess-1"),
......@@ -296,15 +294,15 @@ describe("ProviderCommandReactor", () => {
296294 await Effect.runPromise(
297295 harness.engine.dispatch({
298296 type: "thread.session.stop",
299 commandId: asCommandId("cmd-session-stop"),
300 threadId: asThreadId("thread-1"),
297 commandId: CommandId.makeUnsafe("cmd-session-stop"),
298 threadId: ThreadId.makeUnsafe("thread-1"),
301299 createdAt: now,
302300 }),
303301 );
304302
305303 await waitFor(() => harness.stopSession.mock.calls.length === 1);
306304 const readModel = await Effect.runPromise(harness.engine.getReadModel());
307 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
305 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
308306 expect(thread?.session).not.toBeNull();
309307 expect(thread?.session?.status).toBe("stopped");
310308 expect(thread?.session?.providerSessionId).toBeNull();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+7-9
......@@ -28,9 +28,7 @@ import {
2828} from "../Services/OrchestrationEngine.ts";
2929import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
3030
31const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
3231const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
33const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
3432const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
3533const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
3634const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
......@@ -78,7 +76,7 @@ async function waitForThread(
7876 activities: ReadonlyArray<{ kind: string }>;
7977 }> => {
8078 const readModel = await Effect.runPromise(engine.getReadModel());
81 const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1"));
79 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
8280 if (thread && predicate(thread)) {
8381 return thread;
8482 }
......@@ -132,7 +130,7 @@ describe("ProviderRuntimeIngestion", () => {
132130 await Effect.runPromise(
133131 engine.dispatch({
134132 type: "project.create",
135 commandId: asCommandId("cmd-provider-project-create"),
133 commandId: CommandId.makeUnsafe("cmd-provider-project-create"),
136134 projectId: asProjectId("project-1"),
137135 title: "Provider Project",
138136 workspaceRoot: "/tmp/provider-project",
......@@ -143,8 +141,8 @@ describe("ProviderRuntimeIngestion", () => {
143141 await Effect.runPromise(
144142 engine.dispatch({
145143 type: "thread.create",
146 commandId: asCommandId("cmd-thread-create"),
147 threadId: asThreadId("thread-1"),
144 commandId: CommandId.makeUnsafe("cmd-thread-create"),
145 threadId: ThreadId.makeUnsafe("thread-1"),
148146 projectId: asProjectId("project-1"),
149147 title: "Thread",
150148 model: "gpt-5-codex",
......@@ -156,10 +154,10 @@ describe("ProviderRuntimeIngestion", () => {
156154 await Effect.runPromise(
157155 engine.dispatch({
158156 type: "thread.session.set",
159 commandId: asCommandId("cmd-session-seed"),
160 threadId: asThreadId("thread-1"),
157 commandId: CommandId.makeUnsafe("cmd-session-seed"),
158 threadId: ThreadId.makeUnsafe("thread-1"),
161159 session: {
162 threadId: asThreadId("thread-1"),
160 threadId: ThreadId.makeUnsafe("thread-1"),
163161 status: "ready",
164162 providerName: "codex",
165163 providerSessionId: asSessionId("sess-1"),
apps/server/src/orchestration/Services/OrchestrationEngine.ts+1-13
......@@ -18,7 +18,7 @@ import type {
1818import { ServiceMap } from "effect";
1919import type { Effect, Stream } from "effect";
2020
21import type { OrchestrationDispatchError, OrchestrationEngineError } from "../Errors.ts";
21import type { OrchestrationDispatchError } from "../Errors.ts";
2222import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
2323
2424export interface OrchestrationEngineShape {
......@@ -39,18 +39,6 @@ export interface OrchestrationEngineShape {
3939 fromSequenceExclusive: number,
4040 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;
4141
42 /**
43 * Validate and dispatch an unknown command payload.
44 *
45 * Accepts either parsed values or raw JSON strings.
46 *
47 * @param command - Unknown command payload.
48 * @returns Effect containing the sequence of the persisted event.
49 */
50 readonly dispatchUnknownCommand: (
51 command: unknown,
52 ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError, never>;
53
5442 /**
5543 * Dispatch a validated orchestration command.
5644 *
apps/server/src/orchestration/decider.projectScripts.test.ts+12-14
......@@ -5,10 +5,8 @@ import { Effect } from "effect";
55import { decideOrchestrationCommand } from "./decider.ts";
66import { createEmptyReadModel, projectEvent } from "./projector.ts";
77
8const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
98const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
109const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
11const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
1210const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
1311
1412describe("decider project scripts", () => {
......@@ -20,7 +18,7 @@ describe("decider project scripts", () => {
2018 decideOrchestrationCommand({
2119 command: {
2220 type: "project.create",
23 commandId: asCommandId("cmd-project-create-scripts"),
21 commandId: CommandId.makeUnsafe("cmd-project-create-scripts"),
2422 projectId: asProjectId("project-scripts"),
2523 title: "Scripts",
2624 workspaceRoot: "/tmp/scripts",
......@@ -46,9 +44,9 @@ describe("decider project scripts", () => {
4644 aggregateId: asProjectId("project-scripts"),
4745 type: "project.created",
4846 occurredAt: now,
49 commandId: asCommandId("cmd-project-create-scripts"),
47 commandId: CommandId.makeUnsafe("cmd-project-create-scripts"),
5048 causationEventId: null,
51 correlationId: asCommandId("cmd-project-create-scripts"),
49 correlationId: CommandId.makeUnsafe("cmd-project-create-scripts"),
5250 metadata: {},
5351 payload: {
5452 projectId: asProjectId("project-scripts"),
......@@ -76,7 +74,7 @@ describe("decider project scripts", () => {
7674 decideOrchestrationCommand({
7775 command: {
7876 type: "project.meta.update",
79 commandId: asCommandId("cmd-project-update-scripts"),
77 commandId: CommandId.makeUnsafe("cmd-project-update-scripts"),
8078 projectId: asProjectId("project-scripts"),
8179 scripts: Array.from(scripts),
8280 },
......@@ -100,9 +98,9 @@ describe("decider project scripts", () => {
10098 aggregateId: asProjectId("project-1"),
10199 type: "project.created",
102100 occurredAt: now,
103 commandId: asCommandId("cmd-project-create"),
101 commandId: CommandId.makeUnsafe("cmd-project-create"),
104102 causationEventId: null,
105 correlationId: asCommandId("cmd-project-create"),
103 correlationId: CommandId.makeUnsafe("cmd-project-create"),
106104 metadata: {},
107105 payload: {
108106 projectId: asProjectId("project-1"),
......@@ -120,15 +118,15 @@ describe("decider project scripts", () => {
120118 sequence: 2,
121119 eventId: asEventId("evt-thread-create"),
122120 aggregateKind: "thread",
123 aggregateId: asThreadId("thread-1"),
121 aggregateId: ThreadId.makeUnsafe("thread-1"),
124122 type: "thread.created",
125123 occurredAt: now,
126 commandId: asCommandId("cmd-thread-create"),
124 commandId: CommandId.makeUnsafe("cmd-thread-create"),
127125 causationEventId: null,
128 correlationId: asCommandId("cmd-thread-create"),
126 correlationId: CommandId.makeUnsafe("cmd-thread-create"),
129127 metadata: {},
130128 payload: {
131 threadId: asThreadId("thread-1"),
129 threadId: ThreadId.makeUnsafe("thread-1"),
132130 projectId: asProjectId("project-1"),
133131 title: "Thread",
134132 model: "gpt-5-codex",
......@@ -144,8 +142,8 @@ describe("decider project scripts", () => {
144142 decideOrchestrationCommand({
145143 command: {
146144 type: "thread.turn.start",
147 commandId: asCommandId("cmd-turn-start"),
148 threadId: asThreadId("thread-1"),
145 commandId: CommandId.makeUnsafe("cmd-turn-start"),
146 threadId: ThreadId.makeUnsafe("thread-1"),
149147 message: {
150148 messageId: asMessageId("message-user-1"),
151149 role: "user",
apps/server/src/provider/Layers/CodexAdapter.test.ts+5-6
......@@ -23,7 +23,6 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts";
2323import { makeCodexAdapterLive } from "./CodexAdapter.ts";
2424
2525const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
26const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
2726const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
2827const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
2928const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
......@@ -36,7 +35,7 @@ class FakeCodexManager extends CodexAppServerManager {
3635 sessionId: asSessionId("sess-1"),
3736 provider: "codex",
3837 status: "ready",
39 threadId: asThreadId("thread-1"),
38 threadId: ProviderThreadId.makeUnsafe("thread-1"),
4039 cwd: input.cwd,
4140 createdAt: now,
4241 updatedAt: now,
......@@ -46,7 +45,7 @@ class FakeCodexManager extends CodexAppServerManager {
4645
4746 public sendTurnImpl = vi.fn(
4847 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({
49 threadId: asThreadId("thread-1"),
48 threadId: ProviderThreadId.makeUnsafe("thread-1"),
5049 turnId: asTurnId("turn-1"),
5150 }),
5251 );
......@@ -56,12 +55,12 @@ class FakeCodexManager extends CodexAppServerManager {
5655 );
5756
5857 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({
59 threadId: asThreadId("thread-1"),
58 threadId: ProviderThreadId.makeUnsafe("thread-1"),
6059 turns: [],
6160 }));
6261
6362 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({
64 threadId: asThreadId("thread-1"),
63 threadId: ProviderThreadId.makeUnsafe("thread-1"),
6564 turns: [],
6665 }));
6766
......@@ -194,7 +193,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
194193 sessionId: asSessionId("sess-1"),
195194 createdAt: new Date().toISOString(),
196195 method: "item/completed",
197 threadId: asThreadId("thread-1"),
196 threadId: ProviderThreadId.makeUnsafe("thread-1"),
198197 turnId: asTurnId("turn-1"),
199198 itemId: asItemId("msg_1"),
200199 payload: {
apps/server/src/provider/Layers/ProviderService.test.ts+5-6
......@@ -21,7 +21,7 @@ import {
2121import { it, assert, vi } from "@effect/vitest";
2222import { assertFailure } from "@effect/vitest/utils";
2323
24import { Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Stream } from "effect";
24import { Effect, Fiber, Layer, Option, PubSub, Ref, Stream } from "effect";
2525import * as SqlClient from "effect/unstable/sql/SqlClient";
2626
2727import {
......@@ -46,7 +46,6 @@ import {
4646} from "../../persistence/Layers/Sqlite.ts";
4747
4848const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
49const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
5049const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
5150const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
5251const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
......@@ -568,7 +567,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
568567 provider: "codex",
569568 sessionId: session.sessionId,
570569 createdAt: new Date().toISOString(),
571 threadId: asThreadId("thread-1"),
570 threadId: ThreadId.makeUnsafe("thread-1"),
572571 turnId: asTurnId("turn-1"),
573572 status: "completed",
574573 };
......@@ -616,7 +615,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
616615 provider: "codex",
617616 sessionId: session.sessionId,
618617 createdAt: new Date().toISOString(),
619 threadId: asThreadId("thread-1"),
618 threadId: ThreadId.makeUnsafe("thread-1"),
620619 turnId: asTurnId("turn-1"),
621620 toolKind: "command",
622621 title: "Command run",
......@@ -628,7 +627,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
628627 provider: "codex",
629628 sessionId: session.sessionId,
630629 createdAt: new Date().toISOString(),
631 threadId: asThreadId("thread-1"),
630 threadId: ThreadId.makeUnsafe("thread-1"),
632631 turnId: asTurnId("turn-1"),
633632 delta: "hello",
634633 },
......@@ -638,7 +637,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
638637 provider: "codex",
639638 sessionId: session.sessionId,
640639 createdAt: new Date().toISOString(),
641 threadId: asThreadId("thread-1"),
640 threadId: ThreadId.makeUnsafe("thread-1"),
642641 turnId: asTurnId("turn-1"),
643642 status: "completed",
644643 },
apps/server/src/serverLayers.ts+8
......@@ -4,8 +4,10 @@ import { NodeServices } from "@effect/platform-node";
44import { Effect, Layer } from "effect";
55import * as SqlClient from "effect/unstable/sql/SqlClient";
66
7import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
78import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
89import { ServerConfig } from "./config";
10import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
911import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
1012import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
1113import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
......@@ -55,6 +57,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<
5557 | OrchestrationEngineService
5658 | ProjectionSnapshotQuery
5759 | CheckpointStore
60 | CheckpointDiffQuery
5861 | OrchestrationReactor
5962 | TerminalManager
6063 | Keybindings,
......@@ -67,11 +70,16 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<
6770 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
6871 );
6972 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
73 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
74 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
75 Layer.provideMerge(checkpointStoreLayer),
76 );
7077
7178 const runtimeServicesLayer = Layer.mergeAll(
7279 orchestrationLayer,
7380 OrchestrationProjectionSnapshotQueryLive,
7481 checkpointStoreLayer,
82 checkpointDiffQueryLayer,
7583 );
7684 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
7785 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/terminalManager.test.ts+4-3
......@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest";
1111
1212import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
1313import { TerminalManagerRuntime } from "./terminalManager";
14import { Encoding } from "effect";
1415
1516class FakePtyProcess implements PtyProcess {
1617 readonly writes: string[] = [];
......@@ -107,15 +108,15 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu
107108}
108109
109110function historyLogName(threadId: string): string {
110 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;
111 return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;
111112}
112113
113114function multiTerminalHistoryLogName(threadId: string, terminalId: string): string {
114 const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
115 const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`;
115116 if (terminalId === DEFAULT_TERMINAL_ID) {
116117 return `${threadPart}.log`;
117118 }
118 return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`;
119 return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`;
119120}
120121
121122function historyLogPath(logsDir: string, threadId = "thread-1"): string {
apps/server/src/terminalManager.ts+3-3
......@@ -13,7 +13,7 @@ import {
1313 type TerminalSessionSnapshot,
1414 type TerminalSessionStatus,
1515} from "@t3tools/contracts";
16import { Effect, Layer, Schema, ServiceMap } from "effect";
16import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect";
1717
1818import { createLogger } from "./logger";
1919import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
......@@ -269,11 +269,11 @@ function legacySafeThreadId(threadId: string): string {
269269}
270270
271271function toSafeThreadId(threadId: string): string {
272 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;
272 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
273273}
274274
275275function toSafeTerminalId(terminalId: string): string {
276 return Buffer.from(terminalId, "utf8").toString("base64url");
276 return Encoding.encodeBase64Url(terminalId);
277277}
278278
279279function toSessionKey(threadId: string, terminalId: string): string {
apps/server/src/wsServer.test.ts+21-1
......@@ -175,7 +175,7 @@ class MockTerminalManager implements TerminalManagerShape {
175175 this.sessions.delete(this.key(input.threadId, input.terminalId));
176176 return;
177177 }
178 for (const key of [...this.sessions.keys()]) {
178 for (const key of this.sessions.keys()) {
179179 if (key.startsWith(`${input.threadId}\u0000`)) {
180180 this.sessions.delete(key);
181181 }
......@@ -837,6 +837,26 @@ describe("WebSocket Server", () => {
837837 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
838838 });
839839
840 it("returns error when requesting turn diff with an inverted range", async () => {
841 server = await createTestServer({ cwd: "/test" });
842 const addr = server.address();
843 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
844
845 const ws = await connectWs(port);
846 connections.push(ws);
847 await waitForMessage(ws);
848
849 const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
850 threadId: "thread-any",
851 fromTurnCount: 2,
852 toTurnCount: 1,
853 });
854 expect(response.result).toBeUndefined();
855 expect(response.error?.message).toContain(
856 "fromTurnCount must be less than or equal to toTurnCount",
857 );
858 });
859
840860 it("returns error when requesting full thread diff for unknown thread", async () => {
841861 server = await createTestServer({ cwd: "/test" });
842862 const addr = server.address();
apps/server/src/wsServer.ts+29-153
......@@ -4,19 +4,19 @@ import path from "node:path";
44import type { Duplex } from "node:stream";
55
66import {
7 CheckpointRef,
7 CommandId,
88 ORCHESTRATION_WS_CHANNELS,
99 ORCHESTRATION_WS_METHODS,
10 ProjectId,
1011 TerminalEvent,
1112 WS_CHANNELS,
1213 WS_METHODS,
1314 WebSocketRequest,
14 WebSocketResponse,
1515 WsPush,
1616 WsResponse,
1717} from "@t3tools/contracts";
1818import { NodeHttpServer } from "@effect/platform-node";
19import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect";
19import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream, Struct } from "effect";
2020import { WebSocketServer, type WebSocket } from "ws";
2121
2222import { createLogger } from "./logger";
......@@ -37,7 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati
3737import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
3838import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
3939import { ProviderService } from "./provider/Services/ProviderService";
40import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
40import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
4141import { clamp } from "effect/Number";
4242import { Open } from "./open";
4343import { ServerConfig } from "./config";
......@@ -133,22 +133,14 @@ function websocketRawToString(raw: unknown): string | null {
133133 return null;
134134}
135135
136function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> {
137 const { _tag: _ignoredTag, ...rest } = body;
138 return rest;
139}
140
141const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
142
143function checkpointRefForThreadTurn(threadId: string, turnCount: number): CheckpointRef {
144 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
145 return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
136function stripRequestTag<T extends { _tag: string }>(body: T) {
137 return Struct.omit(body, ["_tag"]);
146138}
147139
148140export type ServerCoreRuntimeServices =
149141 | OrchestrationEngineService
150142 | ProjectionSnapshotQuery
151 | CheckpointStore
143 | CheckpointDiffQuery
152144 | OrchestrationReactor
153145 | ProviderService;
154146
......@@ -301,7 +293,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
301293
302294 const orchestrationEngine = yield* OrchestrationEngineService;
303295 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
304 const liveCheckpointStore = yield* CheckpointStore;
296 const checkpointDiffQuery = yield* CheckpointDiffQuery;
305297 const liveProviderService = yield* ProviderService;
306298 const orchestrationReactor = yield* OrchestrationReactor;
307299 const { openInEditor } = yield* Open;
......@@ -338,10 +330,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
338330 if (!existing) {
339331 const createdAt = new Date().toISOString();
340332 const projectName = path.basename(cwd) || "project";
341 yield* orchestrationEngine.dispatchUnknownCommand({
333 yield* orchestrationEngine.dispatch({
342334 type: "project.create",
343 commandId: crypto.randomUUID(),
344 projectId: crypto.randomUUID(),
335 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
336 projectId: ProjectId.makeUnsafe(crypto.randomUUID()),
345337 title: projectName,
346338 workspaceRoot: cwd,
347339 defaultModel: "gpt-5-codex",
......@@ -351,110 +343,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
351343 }
352344
353345 const routeRequest = Effect.fn(function* (request: WebSocketRequest) {
354 const computeTurnDiff = Effect.fn(function* (input: {
355 readonly threadId: string;
356 readonly fromTurnCount: number;
357 readonly toTurnCount: number;
358 }) {
359 if (input.fromTurnCount > input.toTurnCount) {
360 throw new Error(
361 `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`,
362 );
363 }
364
365 if (input.fromTurnCount === input.toTurnCount) {
366 return {
367 threadId: input.threadId,
368 fromTurnCount: input.fromTurnCount,
369 toTurnCount: input.toTurnCount,
370 diff: "",
371 };
372 }
373
374 const snapshot = yield* projectionReadModelQuery.getSnapshot();
375 const thread = snapshot.threads.find((entry) => entry.id === input.threadId);
376 if (!thread) {
377 throw new Error(`Thread '${input.threadId}' not found.`);
378 }
379
380 const maxTurnCount = thread.checkpoints.reduce(
381 (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount),
382 0,
383 );
384 if (input.toTurnCount > maxTurnCount) {
385 throw new Error(
386 `Turn diff range exceeds current turn count for thread '${input.threadId}': requested ${input.toTurnCount}, current ${maxTurnCount}.`,
387 );
388 }
389
390 const workspaceCwd =
391 thread.worktreePath?.trim() ||
392 snapshot.projects.find((project) => project.id === thread.projectId)?.workspaceRoot?.trim();
393 if (!workspaceCwd) {
394 throw new Error(
395 `Invariant violation: workspace path missing for thread '${input.threadId}' when computing turn diff.`,
396 );
397 }
398
399 const fromCheckpointRef =
400 input.fromTurnCount === 0
401 ? checkpointRefForThreadTurn(input.threadId, 0)
402 : thread.checkpoints.find(
403 (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount,
404 )?.checkpointRef;
405 if (!fromCheckpointRef) {
406 throw new Error(
407 `Checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
408 );
409 }
410
411 const toCheckpointRef = thread.checkpoints.find(
412 (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount,
413 )?.checkpointRef;
414 if (!toCheckpointRef) {
415 throw new Error(
416 `Checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`,
417 );
418 }
419
420 const [fromExists, toExists] = yield* Effect.all(
421 [
422 liveCheckpointStore.hasCheckpointRef({
423 cwd: workspaceCwd,
424 checkpointRef: fromCheckpointRef,
425 }),
426 liveCheckpointStore.hasCheckpointRef({
427 cwd: workspaceCwd,
428 checkpointRef: toCheckpointRef,
429 }),
430 ],
431 { concurrency: "unbounded" },
432 );
433 if (!fromExists) {
434 throw new Error(
435 `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
436 );
437 }
438 if (!toExists) {
439 throw new Error(
440 `Filesystem checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`,
441 );
442 }
443
444 const diff = yield* liveCheckpointStore.diffCheckpoints({
445 cwd: workspaceCwd,
446 fromCheckpointRef,
447 toCheckpointRef,
448 fallbackFromToHead: false,
449 });
450 return {
451 threadId: input.threadId,
452 fromTurnCount: input.fromTurnCount,
453 toTurnCount: input.toTurnCount,
454 diff,
455 };
456 });
457
458346 switch (request.body._tag) {
459347 case ORCHESTRATION_WS_METHODS.getSnapshot:
460348 return yield* projectionReadModelQuery.getSnapshot();
......@@ -466,16 +354,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
466354
467355 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
468356 const body = stripRequestTag(request.body);
469 return yield* computeTurnDiff(body);
357 return yield* checkpointDiffQuery.getTurnDiff(body);
470358 }
471359
472360 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
473361 const body = stripRequestTag(request.body);
474 return yield* computeTurnDiff({
475 threadId: body.threadId,
476 fromTurnCount: 0,
477 toTurnCount: body.toTurnCount,
478 });
362 return yield* checkpointDiffQuery.getFullThreadDiff(body);
479363 }
480364
481365 case ORCHESTRATION_WS_METHODS.replayEvents: {
......@@ -599,46 +483,38 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
599483
600484 const messageText = websocketRawToString(raw);
601485 if (messageText === null) {
602 const errorResponse = yield* encodeResponse(
603 new WebSocketResponse({
604 id: "unknown",
605 error: { message: "Invalid request format: Failed to read message" },
606 }),
607 );
486 const errorResponse = yield* encodeResponse({
487 id: "unknown",
488 error: { message: "Invalid request format: Failed to read message" },
489 });
608490 ws.send(errorResponse);
609491 return;
610492 }
611493
612494 const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText);
613495 if (request._tag === "Failure") {
614 const errorResponse = yield* encodeResponse(
615 new WebSocketResponse({
616 id: "unknown",
617 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
618 }),
619 );
496 const errorResponse = yield* encodeResponse({
497 id: "unknown",
498 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
499 });
620500 ws.send(errorResponse);
621501 return;
622502 }
623503
624504 const result = yield* Effect.exit(routeRequest(request.value));
625505 if (result._tag === "Failure") {
626 const errorResponse = yield* encodeResponse(
627 new WebSocketResponse({
628 id: request.value.id,
629 error: { message: Cause.pretty(result.cause) },
630 }),
631 );
506 const errorResponse = yield* encodeResponse({
507 id: request.value.id,
508 error: { message: Cause.pretty(result.cause) },
509 });
632510 ws.send(errorResponse);
633511 return;
634512 }
635513
636 const response = yield* encodeResponse(
637 new WebSocketResponse({
638 id: request.value.id,
639 result: result.value,
640 }),
641 );
514 const response = yield* encodeResponse({
515 id: request.value.id,
516 result: result.value,
517 });
642518 ws.send(response);
643519 });
644520
apps/web/src/components/BranchToolbar.logic.test.ts+7-4
......@@ -1,4 +1,4 @@
1import type { GitBranch } from "@t3tools/contracts";
1import { ThreadId, type GitBranch } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
44
......@@ -19,8 +19,9 @@ const branches: GitBranch[] = [
1919
2020describe("deriveSyncedLocalBranch", () => {
2121 it("syncs to git current branch in local mode", () => {
22 const threadId = ThreadId.makeUnsafe("thread-1");
2223 const result = deriveSyncedLocalBranch({
23 activeThreadId: "thread-1",
24 activeThreadId: threadId,
2425 activeWorktreePath: null,
2526 envMode: "local",
2627 activeThreadBranch: "feature/demo",
......@@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => {
3132 });
3233
3334 it("does not sync when creating a new worktree", () => {
35 const threadId = ThreadId.makeUnsafe("thread-1");
3436 const result = deriveSyncedLocalBranch({
35 activeThreadId: "thread-1",
37 activeThreadId: threadId,
3638 activeWorktreePath: null,
3739 envMode: "worktree",
3840 activeThreadBranch: "feature/demo",
......@@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => {
4345 });
4446
4547 it("does not sync when thread already targets a worktree path", () => {
48 const threadId = ThreadId.makeUnsafe("thread-1");
4649 const result = deriveSyncedLocalBranch({
47 activeThreadId: "thread-1",
50 activeThreadId: threadId,
4851 activeWorktreePath: "/tmp/repo/worktrees/feature-demo",
4952 envMode: "local",
5053 activeThreadBranch: "feature/demo",
apps/web/src/components/BranchToolbar.logic.ts+2-2
......@@ -1,7 +1,7 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type { GitBranch, ThreadId } from "@t3tools/contracts";
22
33interface DeriveSyncedLocalBranchInput {
4 activeThreadId: string | undefined;
4 activeThreadId: ThreadId | undefined;
55 activeWorktreePath: string | null;
66 envMode: "local" | "worktree";
77 activeThreadBranch: string | null;
apps/web/src/components/BranchToolbar.tsx+14-12
......@@ -1,14 +1,14 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type { GitBranch, ThreadId } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useEffect, useState } from "react";
44
5import { useNativeApi } from "../hooks/useNativeApi";
6import { asThreadId, newCommandId } from "../lib/orchestrationIds";
5import { newCommandId } from "../lib/orchestrationIds";
76import {
87 gitBranchesQueryOptions,
98 gitCheckoutMutationOptions,
109 gitCreateBranchAndCheckoutMutationOptions,
1110} from "../lib/gitReactQuery";
11import { readNativeApi } from "../nativeApi";
1212import { useStore } from "../store";
1313import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
1414import { Button } from "./ui/button";
......@@ -24,7 +24,7 @@ import {
2424import { ChevronDownIcon } from "lucide-react";
2525
2626interface BranchToolbarProps {
27 threadId: string;
27 threadId: ThreadId;
2828 envMode: "local" | "worktree";
2929 onEnvModeChange: (mode: "local" | "worktree") => void;
3030 envLocked: boolean;
......@@ -39,7 +39,6 @@ export default function BranchToolbar({
3939 onComposerFocusRequest,
4040}: BranchToolbarProps) {
4141 const { state, dispatch } = useStore();
42 const api = useNativeApi();
4342 const queryClient = useQueryClient();
4443
4544 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
......@@ -54,7 +53,7 @@ export default function BranchToolbar({
5453
5554 // ── Queries ───────────────────────────────────────────────────────────
5655
57 const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd));
56 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
5857
5958 const branches = branchesQuery.data?.branches ?? [];
6059 const branchNames = branches.map((branch) => branch.name);
......@@ -72,11 +71,11 @@ export default function BranchToolbar({
7271 // ── Mutations ─────────────────────────────────────────────────────────
7372
7473 const checkoutMutation = useMutation(
75 gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
74 gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
7675 );
7776
7877 const createBranchMutation = useMutation(
79 gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),
78 gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
8079 );
8180
8281 // ── Effects ───────────────────────────────────────────────────────────
......@@ -92,12 +91,13 @@ export default function BranchToolbar({
9291 queryBranches,
9392 });
9493 if (!activeThreadId || !syncedBranch) return;
94 const api = readNativeApi();
9595
9696 if (api) {
9797 void api.orchestration.dispatchCommand({
9898 type: "thread.meta.update",
9999 commandId: newCommandId(),
100 threadId: asThreadId(activeThreadId),
100 threadId: activeThreadId,
101101 branch: syncedBranch,
102102 worktreePath: null,
103103 });
......@@ -115,7 +115,6 @@ export default function BranchToolbar({
115115 queryBranches,
116116 envMode,
117117 dispatch,
118 api,
119118 ]);
120119
121120 useEffect(() => {
......@@ -132,6 +131,7 @@ export default function BranchToolbar({
132131
133132 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
134133 if (!activeThreadId) return;
134 const api = readNativeApi();
135135 // If the effective cwd is about to change, stop the running session so the
136136 // next message creates a new one with the correct cwd.
137137 const sessionId = activeThread?.session?.sessionId;
......@@ -140,7 +140,7 @@ export default function BranchToolbar({
140140 .dispatchCommand({
141141 type: "thread.session.stop",
142142 commandId: newCommandId(),
143 threadId: asThreadId(activeThreadId),
143 threadId: activeThreadId,
144144 createdAt: new Date().toISOString(),
145145 })
146146 .catch(() => undefined);
......@@ -149,7 +149,7 @@ export default function BranchToolbar({
149149 void api.orchestration.dispatchCommand({
150150 type: "thread.meta.update",
151151 commandId: newCommandId(),
152 threadId: asThreadId(activeThreadId),
152 threadId: activeThreadId,
153153 branch,
154154 worktreePath,
155155 });
......@@ -158,6 +158,7 @@ export default function BranchToolbar({
158158 };
159159
160160 const selectBranch = (branch: GitBranch) => {
161 const api = readNativeApi();
161162 if (!api || !activeThreadId || !branchCwd) return;
162163
163164 // For new worktree mode, selecting a branch picks the base branch.
......@@ -198,6 +199,7 @@ export default function BranchToolbar({
198199
199200 const createBranch = (rawName: string) => {
200201 const name = rawName.trim();
202 const api = readNativeApi();
201203 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
202204 createBranchMutation.mutate(name, {
203205 onSuccess: () => {
apps/web/src/components/ChatMarkdown.tsx+9-5
......@@ -3,7 +3,7 @@ import type { Components } from "react-markdown";
33import ReactMarkdown from "react-markdown";
44import rehypeHighlight from "rehype-highlight";
55import remarkGfm from "remark-gfm";
6import { useNativeApi } from "../hooks/useNativeApi";
6import { readNativeApi } from "../nativeApi";
77import { resolveMarkdownFileLinkTarget } from "../markdown-links";
88import { preferredTerminalEditor } from "../terminal-links";
99
......@@ -13,12 +13,11 @@ interface ChatMarkdownProps {
1313}
1414
1515function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {
16 const api = useNativeApi();
1716 const markdownComponents = useMemo<Components>(
1817 () => ({
1918 a({ node: _node, href, ...props }) {
2019 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
21 if (!targetPath || !api) {
20 if (!targetPath) {
2221 return <a {...props} href={href} target="_blank" rel="noreferrer" />;
2322 }
2423
......@@ -29,13 +28,18 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {
2928 onClick={(event) => {
3029 event.preventDefault();
3130 event.stopPropagation();
32 void api.shell.openInEditor(targetPath, preferredTerminalEditor());
31 const api = readNativeApi();
32 if (api) {
33 void api.shell.openInEditor(targetPath, preferredTerminalEditor());
34 } else {
35 console.warn("Native API not found. Unable to open file in editor.");
36 }
3337 }}
3438 />
3539 );
3640 },
3741 }),
38 [api, cwd],
42 [cwd],
3943 );
4044
4145 return (
apps/web/src/components/ChatView.tsx+101-98
......@@ -1,15 +1,25 @@
11import {
2 type ApprovalRequestId,
3 DEFAULT_MODEL,
4 DEFAULT_REASONING,
25 EDITORS,
36 type EditorId,
47 type KeybindingCommand,
5 type NativeApi,
8 type MessageId,
9 MODEL_OPTIONS,
10 type ProjectId,
611 type ProjectEntry,
712 type ProjectScript,
813 ModelSlug,
914 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1015 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
16 REASONING_OPTIONS,
17 type ReasoningEffort,
1118 type ResolvedKeybindingsConfig,
1219 type ProviderApprovalDecision,
20 type ThreadId,
21 type TurnId,
22 resolveModelSlug,
1323} from "@t3tools/contracts";
1424import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1525import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -27,14 +37,6 @@ import {
2737 detectComposerTrigger,
2838 replaceTextRange,
2939} from "../composer-logic";
30import {
31 DEFAULT_MODEL,
32 DEFAULT_REASONING,
33 MODEL_OPTIONS,
34 REASONING_OPTIONS,
35 ReasoningEffort,
36 resolveModelSlug,
37} from "../model-logic";
3840import {
3941 derivePendingApprovals,
4042 derivePhase,
......@@ -66,7 +68,6 @@ import {
6668} from "../keybindings";
6769import ChatMarkdown from "./ChatMarkdown";
6870import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
69import { useNativeApi } from "../hooks/useNativeApi";
7071import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
7172import {
7273 BotIcon,
......@@ -101,13 +102,8 @@ import {
101102} from "~/projectScripts";
102103import { Toggle } from "./ui/toggle";
103104import { SidebarTrigger } from "./ui/sidebar";
104import {
105 asApprovalRequestId,
106 asProjectId,
107 asThreadId,
108 newCommandId,
109 newMessageId,
110} from "~/lib/orchestrationIds";
105import { newCommandId, newMessageId } from "~/lib/orchestrationIds";
106import { readNativeApi } from "~/nativeApi";
111107
112108function formatMessageMeta(createdAt: string, duration: string | null): string {
113109 if (!duration) return formatTimestamp(createdAt);
......@@ -326,7 +322,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
326322});
327323
328324interface ChatViewProps {
329 threadId: string;
325 threadId: ThreadId;
330326}
331327
332328export default function ChatView({ threadId }: ChatViewProps) {
......@@ -336,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
336332 strict: false,
337333 select: (params) => parseDiffRouteSearch(params),
338334 });
339 const api = useNativeApi();
340335 const { resolvedTheme } = useTheme();
341336 const queryClient = useQueryClient();
342 const createWorktreeMutation = useMutation(
343 gitCreateWorktreeMutationOptions({ api, queryClient }),
344 );
337 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
345338 const [prompt, setPrompt] = useState("");
346339 const promptRef = useRef(prompt);
347340 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
......@@ -353,7 +346,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
353346 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
354347 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
355348 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
356 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
349 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
357350 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
358351 const [nowTick, setNowTick] = useState(() => Date.now());
359352 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
......@@ -429,7 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
429422 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
430423 useTurnDiffSummaries(activeThread);
431424 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
432 const byMessageId = new Map<string, TurnDiffSummary>();
425 const byMessageId = new Map<MessageId, TurnDiffSummary>();
433426 for (const summary of turnDiffSummaries) {
434427 if (!summary.assistantMessageId) continue;
435428 byMessageId.set(summary.assistantMessageId, summary);
......@@ -437,7 +430,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
437430 return byMessageId;
438431 }, [turnDiffSummaries]);
439432 const revertTurnCountByUserMessageId = useMemo(() => {
440 const byUserMessageId = new Map<string, number>();
433 const byUserMessageId = new Map<MessageId, number>();
441434 for (let index = 0; index < timelineEntries.length; index += 1) {
442435 const entry = timelineEntries[index];
443436 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
......@@ -536,14 +529,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
536529 (debouncerState) => ({ isPending: debouncerState.isPending }),
537530 );
538531 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
539 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
532 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
540533 const keybindingsQuery = useQuery({
541 ...serverConfigQueryOptions(api),
534 ...serverConfigQueryOptions(),
542535 select: (config) => config.keybindings,
543536 });
544537 const workspaceEntriesQuery = useQuery(
545538 projectSearchEntriesQueryOptions({
546 api,
547539 cwd: gitCwd,
548540 query: effectivePathQuery,
549541 enabled: isPathTrigger,
......@@ -689,6 +681,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
689681 );
690682 const closeTerminal = useCallback(
691683 (terminalId: string) => {
684 const api = readNativeApi();
692685 if (!activeThreadId || !api) return;
693686 const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;
694687 const fallbackExitWrite = () =>
......@@ -714,7 +707,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
714707 });
715708 setTerminalFocusRequestId((value) => value + 1);
716709 },
717 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],
710 [activeThread?.terminalIds.length, activeThreadId, dispatch],
718711 );
719712 const runProjectScript = useCallback(
720713 async (
......@@ -727,6 +720,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
727720 rememberAsLastInvoked?: boolean;
728721 },
729722 ) => {
723 const api = readNativeApi();
730724 if (!api || !activeThreadId || !activeProject || !activeThread) return;
731725 if (options?.rememberAsLastInvoked !== false) {
732726 setLastInvokedScriptByProjectId((current) => {
......@@ -799,23 +793,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
799793 });
800794 }
801795 },
802 [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd],
796 [activeProject, activeThread, activeThreadId, dispatch, gitCwd],
803797 );
804798 const persistProjectScripts = useCallback(
805799 async (input: {
806 projectId: string;
800 projectId: ProjectId;
807801 projectCwd: string;
808802 previousScripts: ProjectScript[];
809803 nextScripts: ProjectScript[];
810804 keybinding?: string | null;
811805 keybindingCommand: KeybindingCommand;
812806 }) => {
807 const api = readNativeApi();
813808 if (!api) return;
814809
815810 await api.orchestration.dispatchCommand({
816811 type: "project.meta.update",
817812 commandId: newCommandId(),
818 projectId: asProjectId(input.projectId),
813 projectId: input.projectId,
819814 scripts: input.nextScripts,
820815 });
821816
......@@ -836,7 +831,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
836831 );
837832 }
838833 },
839 [api, queryClient],
834 [queryClient],
840835 );
841836 const saveProjectScript = useCallback(
842837 async (input: NewProjectScriptInput) => {
......@@ -911,6 +906,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
911906 if (mode === state.runtimeMode) return;
912907 dispatch({ type: "SET_RUNTIME_MODE", mode });
913908 scheduleComposerFocus();
909 const api = readNativeApi();
914910 if (!api) return;
915911
916912 const runningThreadIds = state.threads
......@@ -927,7 +923,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
927923 .dispatchCommand({
928924 type: "thread.session.stop",
929925 commandId: newCommandId(),
930 threadId: asThreadId(threadId),
926 threadId,
931927 createdAt: new Date().toISOString(),
932928 })
933929 .catch(() => undefined),
......@@ -1182,7 +1178,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11821178 ]);
11831179
11841180 const setThreadError = useCallback(
1185 (threadId: string | null, error: string | null) => {
1181 (threadId: ThreadId | null, error: string | null) => {
11861182 if (!threadId) return;
11871183 dispatch({
11881184 type: "SET_ERROR",
......@@ -1298,9 +1294,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
12981294
12991295 const onRevertToTurnCount = useCallback(
13001296 async (turnCount: number) => {
1301 if (!api || !activeThread || isRevertingCheckpoint) {
1302 return;
1303 }
1297 const api = readNativeApi();
1298 if (!api || !activeThread || isRevertingCheckpoint) return;
1299
13041300 if (phase === "running" || isSending || isConnecting) {
13051301 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
13061302 return;
......@@ -1322,7 +1318,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13221318 await api.orchestration.dispatchCommand({
13231319 type: "thread.checkpoint.revert",
13241320 commandId: newCommandId(),
1325 threadId: asThreadId(activeThread.id),
1321 threadId: activeThread.id,
13261322 turnCount,
13271323 createdAt: new Date().toISOString(),
13281324 });
......@@ -1335,11 +1331,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
13351331 setIsRevertingCheckpoint(false);
13361332 }
13371333 },
1338 [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
1334 [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
13391335 );
13401336
13411337 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
13421338 e.preventDefault();
1339 const api = readNativeApi();
13431340 if (!api || !activeThread || isSending || isConnecting) return;
13441341 const trimmed = prompt.trim();
13451342 if (!trimmed && composerImages.length === 0) return;
......@@ -1363,7 +1360,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13631360 await api.orchestration.dispatchCommand({
13641361 type: "thread.meta.update",
13651362 commandId: newCommandId(),
1366 threadId: asThreadId(activeThread.id),
1363 threadId: activeThread.id,
13671364 branch: result.worktree.branch,
13681365 worktreePath: result.worktree.path,
13691366 });
......@@ -1397,7 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13971394 await api.orchestration.dispatchCommand({
13981395 type: "thread.meta.update",
13991396 commandId: newCommandId(),
1400 threadId: asThreadId(activeThread.id),
1397 threadId: activeThread.id,
14011398 title,
14021399 });
14031400 }
......@@ -1434,7 +1431,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
14341431 await api.orchestration.dispatchCommand({
14351432 type: "thread.turn.start",
14361433 commandId: newCommandId(),
1437 threadId: asThreadId(activeThread.id),
1434 threadId: activeThread.id,
14381435 message: {
14391436 messageId: newMessageId(),
14401437 role: "user",
......@@ -1456,17 +1453,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
14561453 };
14571454
14581455 const onInterrupt = async () => {
1456 const api = readNativeApi();
14591457 if (!api || !activeThread) return;
14601458 await api.orchestration.dispatchCommand({
14611459 type: "thread.turn.interrupt",
14621460 commandId: newCommandId(),
1463 threadId: asThreadId(activeThread.id),
1461 threadId: activeThread.id,
14641462 createdAt: new Date().toISOString(),
14651463 });
14661464 };
14671465
14681466 const onRespondToApproval = useCallback(
1469 async (requestId: string, decision: ProviderApprovalDecision) => {
1467 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
1468 const api = readNativeApi();
14701469 if (!api || !activeThreadId) return;
14711470
14721471 setRespondingRequestIds((existing) =>
......@@ -1476,8 +1475,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
14761475 await api.orchestration.dispatchCommand({
14771476 type: "thread.approval.respond",
14781477 commandId: newCommandId(),
1479 threadId: asThreadId(activeThreadId),
1480 requestId: asApprovalRequestId(requestId),
1478 threadId: activeThreadId,
1479 requestId,
14811480 decision,
14821481 createdAt: new Date().toISOString(),
14831482 });
......@@ -1491,23 +1490,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
14911490 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
14921491 }
14931492 },
1494 [activeThreadId, api, dispatch],
1493 [activeThreadId, dispatch],
14951494 );
14961495
14971496 const onModelSelect = useCallback(
14981497 (model: ModelSlug) => {
1498 const api = readNativeApi();
14991499 if (!activeThread) return;
15001500 if (api) {
15011501 void api.orchestration.dispatchCommand({
15021502 type: "thread.meta.update",
15031503 commandId: newCommandId(),
1504 threadId: asThreadId(activeThread.id),
1504 threadId: activeThread.id,
15051505 model: resolveModelSlug(model),
15061506 });
15071507 }
15081508 scheduleComposerFocus();
15091509 },
1510 [activeThread, api, scheduleComposerFocus],
1510 [activeThread, scheduleComposerFocus],
15111511 );
15121512 const onEffortSelect = useCallback(
15131513 (effort: ReasoningEffort) => {
......@@ -1629,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16291629 });
16301630 }, [diffOpen, navigate, threadId]);
16311631 const onOpenTurnDiff = useCallback(
1632 (turnId: string, filePath?: string) => {
1632 (turnId: TurnId, filePath?: string) => {
16331633 void navigate({
16341634 to: "/$threadId",
16351635 params: { threadId },
......@@ -1644,7 +1644,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16441644 [navigate, threadId],
16451645 );
16461646 const onRevertUserMessage = useCallback(
1647 (messageId: string) => {
1647 (messageId: MessageId) => {
16481648 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
16491649 if (typeof targetTurnCount !== "number") {
16501650 return;
......@@ -1698,7 +1698,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
16981698 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
16991699 }
17001700 keybindings={keybindings}
1701 api={api}
17021701 gitCwd={gitCwd}
17031702 diffOpen={diffOpen}
17041703 onRunProjectScript={(script) => {
......@@ -1971,35 +1970,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
19711970 />
19721971 )}
19731972
1974 {activeThread.terminalOpen && api && activeProject && (
1975 <ThreadTerminalDrawer
1976 key={activeThread.id}
1977 api={api}
1978 threadId={activeThread.id}
1979 cwd={gitCwd ?? activeProject.cwd}
1980 runtimeEnv={threadTerminalRuntimeEnv}
1981 height={activeThread.terminalHeight}
1982 terminalIds={activeThread.terminalIds}
1983 activeTerminalId={activeThread.activeTerminalId}
1984 terminalGroups={activeThread.terminalGroups}
1985 activeTerminalGroupId={activeThread.activeTerminalGroupId}
1986 focusRequestId={terminalFocusRequestId}
1987 onSplitTerminal={splitTerminal}
1988 onNewTerminal={createNewTerminal}
1989 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
1990 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
1991 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
1992 onActiveTerminalChange={activateTerminal}
1993 onCloseTerminal={closeTerminal}
1994 onHeightChange={(height) =>
1995 dispatch({
1996 type: "SET_THREAD_TERMINAL_HEIGHT",
1997 threadId: activeThread.id,
1998 height,
1999 })
2000 }
2001 />
2002 )}
1973 {(() => {
1974 if (!activeThread.terminalOpen || !activeProject) {
1975 return null;
1976 }
1977 return (
1978 <ThreadTerminalDrawer
1979 key={activeThread.id}
1980 threadId={activeThread.id}
1981 cwd={gitCwd ?? activeProject.cwd}
1982 runtimeEnv={threadTerminalRuntimeEnv}
1983 height={activeThread.terminalHeight}
1984 terminalIds={activeThread.terminalIds}
1985 activeTerminalId={activeThread.activeTerminalId}
1986 terminalGroups={activeThread.terminalGroups}
1987 activeTerminalGroupId={activeThread.activeTerminalGroupId}
1988 focusRequestId={terminalFocusRequestId}
1989 onSplitTerminal={splitTerminal}
1990 onNewTerminal={createNewTerminal}
1991 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
1992 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
1993 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
1994 onActiveTerminalChange={activateTerminal}
1995 onCloseTerminal={closeTerminal}
1996 onHeightChange={(height) =>
1997 dispatch({
1998 type: "SET_THREAD_TERMINAL_HEIGHT",
1999 threadId: activeThread.id,
2000 height,
2001 })
2002 }
2003 />
2004 );
2005 })()}
20032006
20042007 {expandedImage && (
20052008 <div
......@@ -2040,13 +2043,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
20402043}
20412044
20422045interface ChatHeaderProps {
2043 activeThreadId: string;
2046 activeThreadId: ThreadId;
20442047 activeThreadTitle: string;
20452048 activeProjectName: string | undefined;
20462049 activeProjectScripts: ProjectScript[] | undefined;
20472050 preferredScriptId: string | null;
20482051 keybindings: ResolvedKeybindingsConfig;
2049 api: NativeApi | undefined;
20502052 gitCwd: string | null;
20512053 diffOpen: boolean;
20522054 onRunProjectScript: (script: ProjectScript) => void;
......@@ -2062,7 +2064,6 @@ const ChatHeader = memo(function ChatHeader({
20622064 activeProjectScripts,
20632065 preferredScriptId,
20642066 keybindings,
2065 api,
20662067 gitCwd,
20672068 diffOpen,
20682069 onRunProjectScript,
......@@ -2100,9 +2101,7 @@ const ChatHeader = memo(function ChatHeader({
21002101 {activeProjectName && (
21012102 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
21022103 )}
2103 {activeProjectName && (
2104 <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} />
2105 )}
2104 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
21062105 <Toggle
21072106 className="shrink-0"
21082107 pressed={diffOpen}
......@@ -2134,8 +2133,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st
21342133
21352134interface PendingApprovalsPanelProps {
21362135 pendingApprovals: PendingApproval[];
2137 respondingRequestIds: string[];
2138 onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>;
2136 respondingRequestIds: ApprovalRequestId[];
2137 onRespondToApproval: (
2138 requestId: ApprovalRequestId,
2139 decision: ProviderApprovalDecision,
2140 ) => Promise<void>;
21392141}
21402142
21412143const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
......@@ -2211,13 +2213,13 @@ interface MessagesTimelineProps {
22112213 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
22122214 completionDividerBeforeEntryId: string | null;
22132215 completionSummary: string | null;
2214 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2216 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
22152217 nowIso: string;
22162218 expandedWorkGroups: Record<string, boolean>;
22172219 onToggleWorkGroup: (groupId: string) => void;
2218 onOpenTurnDiff: (turnId: string, filePath?: string) => void;
2219 revertTurnCountByUserMessageId: Map<string, number>;
2220 onRevertUserMessage: (messageId: string) => void;
2220 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
2221 revertTurnCountByUserMessageId: Map<MessageId, number>;
2222 onRevertUserMessage: (messageId: MessageId) => void;
22212223 isRevertingCheckpoint: boolean;
22222224 onImageExpand: (image: ExpandedImagePreview) => void;
22232225 markdownCwd: string | undefined;
......@@ -2667,7 +2669,7 @@ const OpenInPicker = memo(function OpenInPicker({
26672669 activeThreadId,
26682670}: {
26692671 keybindings: ResolvedKeybindingsConfig;
2670 activeThreadId: string | null;
2672 activeThreadId: ThreadId | null;
26712673}) {
26722674 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
26732675 const stored = localStorage.getItem(LAST_EDITOR_KEY);
......@@ -2692,13 +2694,13 @@ const OpenInPicker = memo(function OpenInPicker({
26922694 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
26932695 const primaryOption = options.find(({ value }) => value === lastEditor);
26942696
2695 const api = useNativeApi();
26962697 const { state } = useStore();
26972698 const activeThread = state.threads.find((t) => t.id === activeThreadId);
26982699 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
26992700
27002701 const openInEditor = useCallback(
27012702 (editorId: EditorId | null) => {
2703 const api = readNativeApi();
27022704 if (!api || !activeProject) return;
27032705 const editor = editorId ?? lastEditor;
27042706 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
......@@ -2706,7 +2708,7 @@ const OpenInPicker = memo(function OpenInPicker({
27062708 localStorage.setItem(LAST_EDITOR_KEY, editor);
27072709 setLastEditor(editor);
27082710 },
2709 [api, activeProject, activeThread, lastEditor, setLastEditor],
2711 [activeProject, activeThread, lastEditor, setLastEditor],
27102712 );
27112713
27122714 const openFavoriteEditorShortcutLabel = useMemo(
......@@ -2716,6 +2718,7 @@ const OpenInPicker = memo(function OpenInPicker({
27162718
27172719 useEffect(() => {
27182720 const handler = (e: globalThis.KeyboardEvent) => {
2721 const api = readNativeApi();
27192722 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
27202723 if (!api || !activeProject) return;
27212724
......@@ -2725,7 +2728,7 @@ const OpenInPicker = memo(function OpenInPicker({
27252728 };
27262729 window.addEventListener("keydown", handler);
27272730 return () => window.removeEventListener("keydown", handler);
2728 }, [api, activeProject, activeThread, keybindings, lastEditor]);
2731 }, [activeProject, activeThread, keybindings, lastEditor]);
27292732
27302733 return (
27312734 <Group aria-label="Subscription actions">
apps/web/src/components/DiffPanel.tsx+8-11
......@@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs";
22import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
33import { useQuery } from "@tanstack/react-query";
44import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";
56import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
67import { useCallback, useEffect, useMemo, useRef, useState } from "react";
78import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
89import { cn } from "~/lib/utils";
910import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
1011import { isElectron } from "../env";
11import { useNativeApi } from "../hooks/useNativeApi";
1212import { useTheme } from "../hooks/useTheme";
1313import { buildPatchCacheKey } from "../lib/diffRendering";
1414import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
......@@ -81,7 +81,6 @@ interface DiffPanelProps {
8181export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
8282
8383export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
84 const api = useNativeApi();
8584 const navigate = useNavigate();
8685 const { resolvedTheme } = useTheme();
8786 const { state } = useStore();
......@@ -90,13 +89,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
9089 const turnStripRef = useRef<HTMLDivElement>(null);
9190 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
9291 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
93 const params = useParams({ strict: false });
94 const rawSearch = useSearch({ strict: false });
95 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
96 const diffSearch = useMemo(
97 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
98 [rawSearch],
99 );
92 const routeThreadId = useParams({
93 strict: false,
94 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
95 });
96 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
10097 const activeThreadId = routeThreadId;
10198 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
10299 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
......@@ -169,7 +166,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
169166 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
170167 }, [orderedTurnDiffSummaries, selectedTurn]);
171168 const activeCheckpointDiffQuery = useQuery(
172 checkpointDiffQueryOptions(api, {
169 checkpointDiffQueryOptions({
173170 threadId: activeThreadId,
174171 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
175172 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
......@@ -216,7 +213,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
216213 target?.scrollIntoView({ block: "nearest" });
217214 }, [selectedFilePath, renderableFiles]);
218215
219 const selectTurn = (turnId: string) => {
216 const selectTurn = (turnId: TurnId) => {
220217 if (!activeThread) return;
221218 void navigate({
222219 to: "/$threadId",
apps/web/src/components/GitActionsControl.tsx+19-17
......@@ -1,4 +1,4 @@
1import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts";
1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useCallback, useEffect, useMemo, useState } from "react";
44import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
......@@ -39,11 +39,11 @@ import {
3939 invalidateGitQueries,
4040} from "~/lib/gitReactQuery";
4141import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
42import { readNativeApi } from "~/nativeApi";
4243
4344interface GitActionsControlProps {
44 api: NativeApi | undefined;
4545 gitCwd: string | null;
46 activeThreadId: string | null;
46 activeThreadId: ThreadId | null;
4747}
4848
4949function getMenuActionDisabledReason(
......@@ -136,7 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136136 return <InfoIcon className={iconClassName} />;
137137}
138138
139export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) {
139export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
140140 const threadToastData = useMemo(
141141 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
142142 [activeThreadId],
......@@ -145,11 +145,9 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
145145 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
146146 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
147147
148 const { data: gitStatus = null, error: gitStatusError } = useQuery(
149 gitStatusQueryOptions(api, gitCwd),
150 );
148 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
151149
152 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));
150 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
153151 // Default to true while loading so we don't flash init controls.
154152 const isRepo = branchList?.isRepo ?? true;
155153 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
......@@ -163,12 +161,12 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
163161
164162 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
165163
166 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));
164 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
167165
168166 const runImmediateGitActionMutation = useMutation(
169 gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }),
167 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
170168 );
171 const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient }));
169 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
172170
173171 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
174172 const isDefaultBranch = useMemo(() => {
......@@ -192,6 +190,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
192190
193191 const maybeConfirmPushToDefaultBranch = useCallback(
194192 async (action: GitStackedAction): Promise<boolean> => {
193 const api = readNativeApi();
195194 if (!api) return false;
196195 if (
197196 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||
......@@ -203,10 +202,11 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
203202 `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`,
204203 );
205204 },
206 [api, gitStatusForActions?.branch, isDefaultBranch],
205 [gitStatusForActions?.branch, isDefaultBranch],
207206 );
208207
209208 const openExistingPr = useCallback(async () => {
209 const api = readNativeApi();
210210 if (!api) {
211211 toastManager.add({
212212 type: "error",
......@@ -232,8 +232,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
232232 data: threadToastData,
233233 });
234234 });
235 }, [api, gitStatusForActions?.openPr?.url, threadToastData]);
236
235 }, [gitStatusForActions?.openPr?.url, threadToastData]);
237236 const runGitActionWithToast = useCallback(
238237 async ({
239238 action,
......@@ -329,8 +328,10 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
329328 actionProps: {
330329 children: "Open PR",
331330 onClick: () => {
331 const api = readNativeApi();
332 if (!api) return;
332333 closeResultToast();
333 void api?.shell.openExternal(prUrl);
334 void api.shell.openExternal(prUrl);
334335 },
335336 },
336337 }
......@@ -356,8 +357,8 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
356357 });
357358 }
358359 },
360
359361 [
360 api,
361362 gitStatusForActions?.branch,
362363 gitStatusForActions?.hasWorkingTreeChanges,
363364 gitStatusForActions?.openPr?.url,
......@@ -447,6 +448,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
447448
448449 const openChangedFileInEditor = useCallback(
449450 (filePath: string) => {
451 const api = readNativeApi();
450452 if (!api || !gitCwd) {
451453 toastManager.add({
452454 type: "error",
......@@ -465,7 +467,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
465467 });
466468 });
467469 },
468 [api, gitCwd, threadToastData],
470 [gitCwd, threadToastData],
469471 );
470472
471473 if (!gitCwd) return null;
apps/web/src/components/Sidebar.tsx+34-40
......@@ -1,25 +1,23 @@
11import { ChevronRightIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
3import {
4 DEFAULT_MODEL,
5 ProjectId,
6 ThreadId,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
49import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
510import { useNavigate, useParams } from "@tanstack/react-router";
611import { useAppSettings } from "../appSettings";
712import { isElectron } from "../env";
813import { APP_STAGE_LABEL } from "../branding";
9import { DEFAULT_MODEL } from "../model-logic";
10import {
11 asProjectId,
12 asThreadId,
13 newCommandId,
14 newProjectId,
15 newThreadId,
16} from "../lib/orchestrationIds";
14import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
1715import { useStore } from "../store";
1816import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
1917import { type Thread } from "../types";
20import { useNativeApi } from "../hooks/useNativeApi";
2118import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
2219import { serverConfigQueryOptions } from "../lib/serverReactQuery";
20import { readNativeApi } from "../nativeApi";
2321import { toastManager } from "./ui/toast";
2422import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2523import {
......@@ -37,6 +35,7 @@ import {
3735 SidebarTrigger,
3836} from "./ui/sidebar";
3937import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
38import { isNonEmpty as isNonEmptyString } from "effect/String";
4039
4140const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
4241
......@@ -143,33 +142,33 @@ function T3Wordmark() {
143142
144143export default function Sidebar() {
145144 const { state, dispatch } = useStore();
146 const api = useNativeApi();
147145 const navigate = useNavigate();
148146 const { settings: appSettings } = useAppSettings();
149 const params = useParams({ strict: false });
150 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
147 const routeThreadId = useParams({
148 strict: false,
149 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
150 });
151151 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
152 ...serverConfigQueryOptions(api),
152 ...serverConfigQueryOptions(),
153153 select: (config) => config.keybindings,
154154 });
155155 const queryClient = useQueryClient();
156 const removeWorktreeMutation = useMutation(
157 gitRemoveWorktreeMutationOptions({ api, queryClient }),
158 );
156 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
159157 const [addingProject, setAddingProject] = useState(false);
160158 const [newCwd, setNewCwd] = useState("");
161159 const [isPickingFolder, setIsPickingFolder] = useState(false);
162160 const [isAddingProject, setIsAddingProject] = useState(false);
163 const pendingApprovalByThreadId = useMemo(() => new Map<string, boolean>(), []);
161 const pendingApprovalByThreadId = useMemo(() => new Map<ThreadId, boolean>(), []);
164162
165163 const handleNewThread = useCallback(
166164 (
167 projectId: string,
165 projectId: ProjectId,
168166 options?: {
169167 branch?: string | null;
170168 worktreePath?: string | null;
171169 },
172170 ): Promise<void> => {
171 const api = readNativeApi();
173172 if (!api) return Promise.resolve();
174173 const threadId = newThreadId();
175174 const createdAt = new Date().toISOString();
......@@ -180,7 +179,7 @@ export default function Sidebar() {
180179 type: "thread.create",
181180 commandId: newCommandId(),
182181 threadId,
183 projectId: asProjectId(projectId),
182 projectId,
184183 title: "New thread",
185184 model,
186185 branch: options?.branch ?? null,
......@@ -202,11 +201,11 @@ export default function Sidebar() {
202201 });
203202 })();
204203 },
205 [api, dispatch, navigate, state.projects],
204 [dispatch, navigate, state.projects],
206205 );
207206
208207 const focusMostRecentThreadForProject = useCallback(
209 (projectId: string) => {
208 (projectId: ProjectId) => {
210209 const latestThread = state.threads
211210 .filter((thread) => thread.projectId === projectId)
212211 .toSorted((a, b) => {
......@@ -228,6 +227,7 @@ export default function Sidebar() {
228227 async (rawCwd: string) => {
229228 const cwd = rawCwd.trim();
230229 if (!cwd || isAddingProject) return;
230 const api = readNativeApi();
231231
232232 setIsAddingProject(true);
233233 try {
......@@ -240,7 +240,7 @@ export default function Sidebar() {
240240
241241 const projectId = newProjectId();
242242 const createdAt = new Date().toISOString();
243 const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd;
243 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
244244 await api.orchestration.dispatchCommand({
245245 type: "project.create",
246246 commandId: newCommandId(),
......@@ -257,14 +257,7 @@ export default function Sidebar() {
257257 setAddingProject(false);
258258 }
259259 },
260 [
261 api,
262 focusMostRecentThreadForProject,
263 handleNewThread,
264 isAddingProject,
265 state.projects,
266 state.threads,
267 ],
260 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects],
268261 );
269262
270263 const handleAddProject = () => {
......@@ -272,6 +265,7 @@ export default function Sidebar() {
272265 };
273266
274267 const handlePickFolder = async () => {
268 const api = readNativeApi();
275269 if (!api || isPickingFolder) return;
276270 setIsPickingFolder(true);
277271 try {
......@@ -284,7 +278,8 @@ export default function Sidebar() {
284278 };
285279
286280 const handleThreadContextMenu = useCallback(
287 async (threadId: string, position: { x: number; y: number }) => {
281 async (threadId: ThreadId, position: { x: number; y: number }) => {
282 const api = readNativeApi();
288283 if (!api) return;
289284 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
290285 if (clicked !== "delete") return;
......@@ -324,7 +319,7 @@ export default function Sidebar() {
324319 .dispatchCommand({
325320 type: "thread.session.stop",
326321 commandId: newCommandId(),
327 threadId: asThreadId(threadId),
322 threadId,
328323 createdAt: new Date().toISOString(),
329324 })
330325 .catch(() => undefined);
......@@ -344,7 +339,7 @@ export default function Sidebar() {
344339 await api.orchestration.dispatchCommand({
345340 type: "thread.delete",
346341 commandId: newCommandId(),
347 threadId: asThreadId(threadId),
342 threadId,
348343 });
349344 if (shouldNavigateToFallback) {
350345 if (fallbackThreadId) {
......@@ -384,9 +379,7 @@ export default function Sidebar() {
384379 }
385380 },
386381 [
387 api,
388382 appSettings.confirmThreadDelete,
389 dispatch,
390383 navigate,
391384 removeWorktreeMutation,
392385 routeThreadId,
......@@ -396,7 +389,8 @@ export default function Sidebar() {
396389 );
397390
398391 const handleProjectContextMenu = useCallback(
399 async (projectId: string, position: { x: number; y: number }) => {
392 async (projectId: ProjectId, position: { x: number; y: number }) => {
393 const api = readNativeApi();
400394 if (!api) return;
401395 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
402396 if (clicked !== "delete") return;
......@@ -423,7 +417,7 @@ export default function Sidebar() {
423417 await api.orchestration.dispatchCommand({
424418 type: "project.delete",
425419 commandId: newCommandId(),
426 projectId: asProjectId(projectId),
420 projectId,
427421 });
428422 } catch (error) {
429423 const message = error instanceof Error ? error.message : "Unknown error deleting project.";
......@@ -435,7 +429,7 @@ export default function Sidebar() {
435429 });
436430 }
437431 },
438 [api, state.projects, state.threads],
432 [state.projects, state.threads],
439433 );
440434
441435 useEffect(() => {
......@@ -687,7 +681,7 @@ export default function Sidebar() {
687681 if (event.key === "Escape") setAddingProject(false);
688682 }}
689683 />
690 {isElectron && api && (
684 {isElectron && (
691685 <button
692686 type="button"
693687 className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
apps/web/src/components/ThreadTerminalDrawer.tsx+14-20
......@@ -1,6 +1,6 @@
11import { FitAddon } from "@xterm/addon-fit";
22import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
3import { type NativeApi } from "@t3tools/contracts";
3import { type ThreadId } from "@t3tools/contracts";
44import { Terminal, type ITheme } from "@xterm/xterm";
55import {
66 type PointerEvent as ReactPointerEvent,
......@@ -25,6 +25,7 @@ import {
2525 MAX_THREAD_TERMINAL_COUNT,
2626 type ThreadTerminalGroup,
2727} from "../types";
28import { readNativeApi } from "~/nativeApi";
2829
2930const MIN_DRAWER_HEIGHT = 180;
3031const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -107,8 +108,7 @@ function terminalThemeFromApp(): ITheme {
107108}
108109
109110interface TerminalViewportProps {
110 api: NativeApi;
111 threadId: string;
111 threadId: ThreadId;
112112 terminalId: string;
113113 cwd: string;
114114 runtimeEnv?: Record<string, string>;
......@@ -119,7 +119,6 @@ interface TerminalViewportProps {
119119}
120120
121121function TerminalViewport({
122 api,
123122 threadId,
124123 terminalId,
125124 cwd,
......@@ -155,6 +154,9 @@ function TerminalViewport({
155154 terminalRef.current = terminal;
156155 fitAddonRef.current = fitAddon;
157156
157 const api = readNativeApi();
158 if (!api) return;
159
158160 const sendTerminalInput = async (data: string, fallbackError: string) => {
159161 const activeTerminal = terminalRef.current;
160162 if (!activeTerminal) return;
......@@ -210,14 +212,10 @@ function TerminalViewport({
210212 end: { x: match.end, y: bufferLineNumber },
211213 },
212214 activate: (event: MouseEvent) => {
213 if (!isTerminalLinkActivation(event)) {
214 return;
215 }
215 if (!isTerminalLinkActivation(event)) return;
216216
217217 const latestTerminal = terminalRef.current;
218 if (!latestTerminal) {
219 return;
220 }
218 if (!latestTerminal) return;
221219
222220 if (match.kind === "url") {
223221 void api.shell.openExternal(match.text).catch((error) => {
......@@ -297,7 +295,7 @@ function TerminalViewport({
297295 }
298296 };
299297
300 const unsubscribe = api.terminal.onEvent((event) => {
298 const unsubscribe = api?.terminal.onEvent((event) => {
301299 if (event.threadId !== threadId || event.terminalId !== terminalId) return;
302300 const activeTerminal = terminalRef.current;
303301 if (!activeTerminal) return;
......@@ -372,7 +370,7 @@ function TerminalViewport({
372370 fitAddonRef.current = null;
373371 terminal.dispose();
374372 };
375 }, [api, cwd, runtimeEnv, terminalId, threadId]);
373 }, [cwd, runtimeEnv, terminalId, threadId]);
376374
377375 useEffect(() => {
378376 if (!autoFocus) return;
......@@ -387,9 +385,10 @@ function TerminalViewport({
387385 }, [autoFocus, focusRequestId]);
388386
389387 useEffect(() => {
388 const api = readNativeApi();
390389 const terminal = terminalRef.current;
391390 const fitAddon = fitAddonRef.current;
392 if (!terminal || !fitAddon) return;
391 if (!api || !terminal || !fitAddon) return;
393392 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
394393 const frame = window.requestAnimationFrame(() => {
395394 fitAddon.fit();
......@@ -408,14 +407,12 @@ function TerminalViewport({
408407 return () => {
409408 window.cancelAnimationFrame(frame);
410409 };
411 }, [api, drawerHeight, resizeEpoch, terminalId, threadId]);
412
410 }, [drawerHeight, resizeEpoch, terminalId, threadId]);
413411 return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;
414412}
415413
416414interface ThreadTerminalDrawerProps {
417 api: NativeApi;
418 threadId: string;
415 threadId: ThreadId;
419416 cwd: string;
420417 runtimeEnv?: Record<string, string>;
421418 height: number;
......@@ -464,7 +461,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
464461}
465462
466463export default function ThreadTerminalDrawer({
467 api,
468464 threadId,
469465 cwd,
470466 runtimeEnv,
......@@ -783,7 +779,6 @@ export default function ThreadTerminalDrawer({
783779 >
784780 <div className="h-full p-1">
785781 <TerminalViewport
786 api={api}
787782 threadId={threadId}
788783 terminalId={terminalId}
789784 cwd={cwd}
......@@ -801,7 +796,6 @@ export default function ThreadTerminalDrawer({
801796 <div className="h-full p-1">
802797 <TerminalViewport
803798 key={resolvedActiveTerminalId}
804 api={api}
805799 threadId={threadId}
806800 terminalId={resolvedActiveTerminalId}
807801 cwd={cwd}
apps/web/src/components/ui/command.tsx+4-1
......@@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc
133133function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
134134 return (
135135 <div
136 className="-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2"
136 className={cn(
137 "-mx-px not-has-[+[data-slot=command-footer]]:-mb-px relative min-h-0 rounded-t-xl not-has-[+[data-slot=command-footer]]:rounded-b-2xl border border-b-0 bg-popover bg-clip-padding shadow-xs/5 [clip-path:inset(0_1px)] not-has-[+[data-slot=command-footer]]:[clip-path:inset(0_1px_1px_1px_round_0_0_calc(var(--radius-2xl)-1px)_calc(var(--radius-2xl)-1px))] before:pointer-events-none before:absolute before:inset-0 before:rounded-t-[calc(var(--radius-xl)-1px)] **:data-[slot=scroll-area-scrollbar]:mt-2",
138 className,
139 )}
137140 {...props}
138141 />
139142 );
apps/web/src/components/ui/sheet.tsx+1
......@@ -49,6 +49,7 @@ function SheetViewport({
4949 side === "left" && "flex justify-start",
5050 side === "right" && "flex justify-end",
5151 variant === "inset" && "sm:p-4",
52 className,
5253 )}
5354 data-slot="sheet-viewport"
5455 {...props}
apps/web/src/components/ui/toast.tsx+6-4
......@@ -3,6 +3,7 @@
33import { Toast } from "@base-ui/react/toast";
44import { useEffect } from "react";
55import { useParams } from "@tanstack/react-router";
6import { ThreadId } from "@t3tools/contracts";
67import {
78 CircleAlertIcon,
89 CircleCheckIcon,
......@@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button";
1617import { shouldHideCollapsedToastContent } from "./toast.logic";
1718
1819type ThreadToastData = {
19 threadId?: string | null;
20 threadId?: ThreadId | null;
2021 tooltipStyle?: boolean;
2122 dismissAfterVisibleMs?: number;
2223};
......@@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props {
4849
4950function shouldRenderForActiveThread(
5051 data: ThreadToastData | undefined,
51 activeThreadId: string | null,
52 activeThreadId: ThreadId | null,
5253): boolean {
5354 const toastThreadId = data?.threadId;
5455 if (!toastThreadId) return true;
5556 return toastThreadId === activeThreadId;
5657}
5758
58function useActiveThreadIdFromRoute(): string | null {
59function useActiveThreadIdFromRoute(): ThreadId | null {
5960 return useParams({
6061 strict: false,
61 select: (params) => params.threadId ?? null,
62 select: (params) =>
63 typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null,
6264 });
6365}
6466
apps/web/src/diffRouteSearch.ts+5-2
......@@ -1,6 +1,8 @@
1import { TurnId } from "@t3tools/contracts";
2
13export interface DiffRouteSearch {
24 diff?: "1";
3 diffTurnId?: string;
5 diffTurnId?: TurnId;
46 diffFilePath?: string;
57}
68
......@@ -25,7 +27,8 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>(
2527
2628export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {
2729 const diff = isDiffOpenValue(search.diff) ? "1" : undefined;
28 const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined;
30 const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined;
31 const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined;
2932 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
3033
3134 return {
apps/web/src/historyBootstrap.test.ts+10-7
......@@ -1,20 +1,23 @@
1import { MessageId } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23
34import { buildBootstrapInput } from "./historyBootstrap";
45
6const messageId = (value: string) => MessageId.makeUnsafe(value);
7
58describe("buildBootstrapInput", () => {
69 it("includes full transcript when under budget", () => {
710 const result = buildBootstrapInput(
811 [
912 {
10 id: "u-1",
13 id: messageId("u-1"),
1114 role: "user",
1215 text: "hello",
1316 createdAt: "2026-02-09T00:00:00.000Z",
1417 streaming: false,
1518 },
1619 {
17 id: "a-1",
20 id: messageId("a-1"),
1821 role: "assistant",
1922 text: "world",
2023 createdAt: "2026-02-09T00:00:01.000Z",
......@@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => {
3841 const result = buildBootstrapInput(
3942 [
4043 {
41 id: "u-1",
44 id: messageId("u-1"),
4245 role: "user",
4346 text: "first question with details",
4447 createdAt: "2026-02-09T00:00:00.000Z",
4548 streaming: false,
4649 },
4750 {
48 id: "a-1",
51 id: messageId("a-1"),
4952 role: "assistant",
5053 text: "first answer with details",
5154 createdAt: "2026-02-09T00:00:01.000Z",
5255 streaming: false,
5356 },
5457 {
55 id: "u-2",
58 id: messageId("u-2"),
5659 role: "user",
5760 text: "second question with details",
5861 createdAt: "2026-02-09T00:00:02.000Z",
......@@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => {
7578 const result = buildBootstrapInput(
7679 [
7780 {
78 id: "u-1",
81 id: messageId("u-1"),
7982 role: "user",
8083 text: "old context",
8184 createdAt: "2026-02-09T00:00:00.000Z",
......@@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => {
9699 const result = buildBootstrapInput(
97100 [
98101 {
99 id: "u-image",
102 id: messageId("u-image"),
100103 role: "user",
101104 text: "",
102105 attachments: [
apps/web/src/hooks/useNativeApi.ts deleted-6
......@@ -1,6 +0,0 @@
1import { useMemo } from "react";
2import { readNativeApi } from "../nativeApi";
3
4export function useNativeApi() {
5 return useMemo(() => readNativeApi(), []);
6}
apps/web/src/lib/gitReactQuery.ts+36-53
......@@ -1,5 +1,6 @@
1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";
1import type { GitStackedAction } from "@t3tools/contracts";
22import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";
34
45const GIT_STATUS_STALE_TIME_MS = 5_000;
56const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
......@@ -16,16 +17,15 @@ export function invalidateGitQueries(queryClient: QueryClient) {
1617 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
1718}
1819
19export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) {
20export function gitStatusQueryOptions(cwd: string | null) {
2021 return queryOptions({
2122 queryKey: gitQueryKeys.status(cwd),
2223 queryFn: async () => {
23 if (!api || !cwd) {
24 throw new Error("Git status is unavailable.");
25 }
24 const api = ensureNativeApi();
25 if (!cwd) throw new Error("Git status is unavailable.");
2626 return api.git.status({ cwd });
2727 },
28 enabled: !!api && !!cwd,
28 enabled: cwd !== null,
2929 staleTime: GIT_STATUS_STALE_TIME_MS,
3030 refetchOnWindowFocus: "always",
3131 refetchOnReconnect: "always",
......@@ -33,16 +33,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |
3333 });
3434}
3535
36export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) {
36export function gitBranchesQueryOptions(cwd: string | null) {
3737 return queryOptions({
3838 queryKey: gitQueryKeys.branches(cwd),
3939 queryFn: async () => {
40 if (!api || !cwd) {
41 throw new Error("Git branches are unavailable.");
42 }
40 const api = ensureNativeApi();
41 if (!cwd) throw new Error("Git branches are unavailable.");
4342 return api.git.listBranches({ cwd });
4443 },
45 enabled: !!api && !!cwd,
44 enabled: cwd !== null,
4645 staleTime: GIT_BRANCHES_STALE_TIME_MS,
4746 refetchOnWindowFocus: true,
4847 refetchOnReconnect: true,
......@@ -50,15 +49,12 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string
5049 });
5150}
5251
53export function gitInitMutationOptions(input: {
54 api: NativeApi | undefined;
55 cwd: string | null;
56 queryClient: QueryClient;
57}) {
52export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
5853 return mutationOptions({
5954 mutationFn: async () => {
60 if (!input.api || !input.cwd) throw new Error("Git init is unavailable.");
61 return input.api.git.init({ cwd: input.cwd });
55 const api = ensureNativeApi();
56 if (!input.cwd) throw new Error("Git init is unavailable.");
57 return api.git.init({ cwd: input.cwd });
6258 },
6359 onSuccess: async () => {
6460 await invalidateGitQueries(input.queryClient);
......@@ -67,14 +63,14 @@ export function gitInitMutationOptions(input: {
6763}
6864
6965export function gitCheckoutMutationOptions(input: {
70 api: NativeApi | undefined;
7166 cwd: string | null;
7267 queryClient: QueryClient;
7368}) {
7469 return mutationOptions({
7570 mutationFn: async (branch: string) => {
76 if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable.");
77 return input.api.git.checkout({ cwd: input.cwd, branch });
71 const api = ensureNativeApi();
72 if (!input.cwd) throw new Error("Git checkout is unavailable.");
73 return api.git.checkout({ cwd: input.cwd, branch });
7874 },
7975 onSuccess: async () => {
8076 await invalidateGitQueries(input.queryClient);
......@@ -83,15 +79,15 @@ export function gitCheckoutMutationOptions(input: {
8379}
8480
8581export function gitCreateBranchAndCheckoutMutationOptions(input: {
86 api: NativeApi | undefined;
8782 cwd: string | null;
8883 queryClient: QueryClient;
8984}) {
9085 return mutationOptions({
9186 mutationFn: async (branch: string) => {
92 if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable.");
93 await input.api.git.createBranch({ cwd: input.cwd, branch });
94 return input.api.git.checkout({ cwd: input.cwd, branch });
87 const api = ensureNativeApi();
88 if (!input.cwd) throw new Error("Git branch creation is unavailable.");
89 await api.git.createBranch({ cwd: input.cwd, branch });
90 return api.git.checkout({ cwd: input.cwd, branch });
9591 },
9692 onSuccess: async () => {
9793 await invalidateGitQueries(input.queryClient);
......@@ -100,7 +96,6 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: {
10096}
10197
10298export function gitRunStackedActionMutationOptions(input: {
103 api: NativeApi | undefined;
10499 cwd: string | null;
105100 queryClient: QueryClient;
106101}) {
......@@ -112,10 +107,9 @@ export function gitRunStackedActionMutationOptions(input: {
112107 action: GitStackedAction;
113108 commitMessage?: string;
114109 }) => {
115 if (!input.api || !input.cwd) {
116 throw new Error("Git action is unavailable.");
117 }
118 return input.api.git.runStackedAction({
110 const api = ensureNativeApi();
111 if (!input.cwd) throw new Error("Git action is unavailable.");
112 return api.git.runStackedAction({
119113 cwd: input.cwd,
120114 action,
121115 ...(commitMessage ? { commitMessage } : {}),
......@@ -127,15 +121,12 @@ export function gitRunStackedActionMutationOptions(input: {
127121 });
128122}
129123
130export function gitPullMutationOptions(input: {
131 api: NativeApi | undefined;
132 cwd: string | null;
133 queryClient: QueryClient;
134}) {
124export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
135125 return mutationOptions({
136126 mutationFn: async () => {
137 if (!input.api || !input.cwd) throw new Error("Git pull is unavailable.");
138 return input.api.git.pull({ cwd: input.cwd });
127 const api = ensureNativeApi();
128 if (!input.cwd) throw new Error("Git pull is unavailable.");
129 return api.git.pull({ cwd: input.cwd });
139130 },
140131 onSettled: async () => {
141132 await invalidateGitQueries(input.queryClient);
......@@ -143,10 +134,7 @@ export function gitPullMutationOptions(input: {
143134 });
144135}
145136
146export function gitCreateWorktreeMutationOptions(input: {
147 api: NativeApi | undefined;
148 queryClient: QueryClient;
149}) {
137export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
150138 return mutationOptions({
151139 mutationFn: async ({
152140 cwd,
......@@ -159,10 +147,9 @@ export function gitCreateWorktreeMutationOptions(input: {
159147 newBranch: string;
160148 path?: string | null;
161149 }) => {
162 if (!input.api) {
163 throw new Error("Git worktree creation is unavailable.");
164 }
165 return input.api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
150 const api = ensureNativeApi();
151 if (!cwd) throw new Error("Git worktree creation is unavailable.");
152 return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
166153 },
167154 onSettled: async () => {
168155 await invalidateGitQueries(input.queryClient);
......@@ -170,16 +157,12 @@ export function gitCreateWorktreeMutationOptions(input: {
170157 });
171158}
172159
173export function gitRemoveWorktreeMutationOptions(input: {
174 api: NativeApi | undefined;
175 queryClient: QueryClient;
176}) {
160export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
177161 return mutationOptions({
178162 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
179 if (!input.api) {
180 throw new Error("Git worktree removal is unavailable.");
181 }
182 return input.api.git.removeWorktree({ cwd, path, force });
163 const api = ensureNativeApi();
164 if (!cwd) throw new Error("Git worktree removal is unavailable.");
165 return api.git.removeWorktree({ cwd, path, force });
183166 },
184167 onSettled: async () => {
185168 await invalidateGitQueries(input.queryClient);
apps/web/src/lib/orchestrationIds.ts-1
......@@ -8,7 +8,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID
88
99export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID());
1010
11export const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
1211
1312export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
1413
apps/web/src/lib/projectReactQuery.ts+6-5
......@@ -1,5 +1,6 @@
1import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts";
1import type { ProjectSearchEntriesResult } from "@t3tools/contracts";
22import { queryOptions } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";
34
45export const projectQueryKeys = {
56 all: ["projects"] as const,
......@@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
1516};
1617
1718export function projectSearchEntriesQueryOptions(input: {
18 api: NativeApi | undefined;
1919 cwd: string | null;
2020 query: string;
2121 enabled?: boolean;
......@@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: {
2626 return queryOptions({
2727 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
2828 queryFn: async () => {
29 if (!input.api || !input.cwd) {
29 const api = ensureNativeApi();
30 if (!input.cwd) {
3031 throw new Error("Workspace entry search is unavailable.");
3132 }
32 return input.api.projects.searchEntries({
33 return api.projects.searchEntries({
3334 cwd: input.cwd,
3435 query: input.query,
3536 limit,
3637 });
3738 },
38 enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd),
39 enabled: (input.enabled ?? true) && input.cwd !== null,
3940 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
4041 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
4142 });
apps/web/src/lib/providerReactQuery.test.ts+57-27
......@@ -1,12 +1,31 @@
1import type { NativeApi } from "@t3tools/contracts";
1import { ThreadId, type NativeApi } from "@t3tools/contracts";
22import { QueryClient } from "@tanstack/react-query";
3import { describe, expect, it, vi } from "vitest";
3import { afterEach, describe, expect, it, vi } from "vitest";
44import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";
5import * as nativeApi from "../nativeApi";
6
7const threadId = ThreadId.makeUnsafe("thread-id");
8
9function mockNativeApi(input: {
10 getTurnDiff: ReturnType<typeof vi.fn>;
11 getFullThreadDiff: ReturnType<typeof vi.fn>;
12}) {
13 vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({
14 orchestration: {
15 getTurnDiff: input.getTurnDiff,
16 getFullThreadDiff: input.getFullThreadDiff,
17 },
18 } as unknown as NativeApi);
19}
20
21afterEach(() => {
22 vi.restoreAllMocks();
23});
524
625describe("providerQueryKeys.checkpointDiff", () => {
726 it("includes cacheScope so reused turn counts do not collide", () => {
827 const baseInput = {
9 threadId: "thread-id",
28 threadId,
1029 fromTurnCount: 1,
1130 toTurnCount: 2,
1231 } as const;
......@@ -29,15 +48,10 @@ describe("checkpointDiffQueryOptions", () => {
2948 it("forwards checkpoint range to the provider API", async () => {
3049 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
3150 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
32 const api = {
33 orchestration: {
34 getTurnDiff,
35 getFullThreadDiff,
36 },
37 } as unknown as NativeApi;
38
39 const options = checkpointDiffQueryOptions(api, {
40 threadId: "thread-id",
51 mockNativeApi({ getTurnDiff, getFullThreadDiff });
52
53 const options = checkpointDiffQueryOptions({
54 threadId,
4155 fromTurnCount: 3,
4256 toTurnCount: 4,
4357 cacheScope: "turn:abc",
......@@ -47,7 +61,7 @@ describe("checkpointDiffQueryOptions", () => {
4761 await queryClient.fetchQuery(options);
4862
4963 expect(getTurnDiff).toHaveBeenCalledWith({
50 threadId: "thread-id",
64 threadId,
5165 fromTurnCount: 3,
5266 toTurnCount: 4,
5367 });
......@@ -57,15 +71,10 @@ describe("checkpointDiffQueryOptions", () => {
5771 it("uses explicit full thread diff API when range starts from zero", async () => {
5872 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
5973 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
60 const api = {
61 orchestration: {
62 getTurnDiff,
63 getFullThreadDiff,
64 },
65 } as unknown as NativeApi;
66
67 const options = checkpointDiffQueryOptions(api, {
68 threadId: "thread-id",
74 mockNativeApi({ getTurnDiff, getFullThreadDiff });
75
76 const options = checkpointDiffQueryOptions({
77 threadId,
6978 fromTurnCount: 0,
7079 toTurnCount: 2,
7180 cacheScope: "thread:all",
......@@ -75,15 +84,36 @@ describe("checkpointDiffQueryOptions", () => {
7584 await queryClient.fetchQuery(options);
7685
7786 expect(getFullThreadDiff).toHaveBeenCalledWith({
78 threadId: "thread-id",
87 threadId,
7988 toTurnCount: 2,
8089 });
8190 expect(getTurnDiff).not.toHaveBeenCalled();
8291 });
8392
93 it("fails fast on invalid range and does not call provider RPC", async () => {
94 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
95 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
96 mockNativeApi({ getTurnDiff, getFullThreadDiff });
97
98 const options = checkpointDiffQueryOptions({
99 threadId,
100 fromTurnCount: 4,
101 toTurnCount: 3,
102 cacheScope: "turn:invalid",
103 });
104
105 const queryClient = new QueryClient();
106
107 await expect(queryClient.fetchQuery(options)).rejects.toThrow(
108 "Checkpoint diff is unavailable.",
109 );
110 expect(getTurnDiff).not.toHaveBeenCalled();
111 expect(getFullThreadDiff).not.toHaveBeenCalled();
112 });
113
84114 it("retries checkpoint-not-ready errors longer than generic failures", () => {
85 const options = checkpointDiffQueryOptions(undefined, {
86 threadId: "thread-id",
115 const options = checkpointDiffQueryOptions({
116 threadId,
87117 fromTurnCount: 1,
88118 toTurnCount: 2,
89119 cacheScope: "turn:abc",
......@@ -106,8 +136,8 @@ describe("checkpointDiffQueryOptions", () => {
106136 });
107137
108138 it("backs off longer for checkpoint-not-ready errors", () => {
109 const options = checkpointDiffQueryOptions(undefined, {
110 threadId: "thread-id",
139 const options = checkpointDiffQueryOptions({
140 threadId,
111141 fromTurnCount: 1,
112142 toTurnCount: 2,
113143 cacheScope: "turn:abc",
apps/web/src/lib/providerReactQuery.ts+32-32
......@@ -1,9 +1,14 @@
1import type { NativeApi } from "@t3tools/contracts";
1import {
2 OrchestrationGetFullThreadDiffInput,
3 OrchestrationGetTurnDiffInput,
4 ThreadId,
5} from "@t3tools/contracts";
26import { queryOptions } from "@tanstack/react-query";
3import { asThreadId } from "./orchestrationIds";
7import { Option, Schema } from "effect";
8import { ensureNativeApi } from "../nativeApi";
49
5export interface CheckpointDiffQueryInput {
6 threadId: string | null;
10interface CheckpointDiffQueryInput {
11 threadId: ThreadId | null;
712 fromTurnCount: number | null;
813 toTurnCount: number | null;
914 cacheScope?: string | null;
......@@ -22,6 +27,21 @@ export const providerQueryKeys = {
2227 ] as const,
2328};
2429
30function decodeCheckpointDiffRequest(input: CheckpointDiffQueryInput) {
31 if (input.fromTurnCount === 0) {
32 return Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput)({
33 threadId: input.threadId,
34 toTurnCount: input.toTurnCount,
35 }).pipe(Option.map((fields) => ({ kind: "fullThreadDiff" as const, input: fields })));
36 }
37
38 return Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput)({
39 threadId: input.threadId,
40 fromTurnCount: input.fromTurnCount,
41 toTurnCount: input.toTurnCount,
42 }).pipe(Option.map((fields) => ({ kind: "turnDiff" as const, input: fields })));
43}
44
2545function asCheckpointErrorMessage(error: unknown): string {
2646 if (error instanceof Error) {
2747 return error.message;
......@@ -41,42 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
4161 );
4262}
4363
44export function checkpointDiffQueryOptions(
45 api: NativeApi | undefined,
46 input: CheckpointDiffQueryInput,
47) {
48 const hasValidRange =
49 typeof input.fromTurnCount === "number" &&
50 typeof input.toTurnCount === "number" &&
51 Number.isInteger(input.fromTurnCount) &&
52 Number.isInteger(input.toTurnCount) &&
53 input.fromTurnCount >= 0 &&
54 input.toTurnCount >= 0 &&
55 input.fromTurnCount <= input.toTurnCount;
64export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
65 const decodedRequest = decodeCheckpointDiffRequest(input);
5666
5767 return queryOptions({
5868 queryKey: providerQueryKeys.checkpointDiff(input),
5969 queryFn: async () => {
60 if (!api || !input.threadId || !hasValidRange) {
70 const api = ensureNativeApi();
71 if (!input.threadId || decodedRequest._tag === "None") {
6172 throw new Error("Checkpoint diff is unavailable.");
6273 }
63 const { fromTurnCount, toTurnCount } = input;
64 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
65 throw new Error("Checkpoint diff range is invalid.");
66 }
67 if (fromTurnCount === 0) {
68 return api.orchestration.getFullThreadDiff({
69 threadId: asThreadId(input.threadId),
70 toTurnCount,
71 });
74 if (decodedRequest.value.kind === "fullThreadDiff") {
75 return api.orchestration.getFullThreadDiff(decodedRequest.value.input);
7276 }
73 return api.orchestration.getTurnDiff({
74 threadId: asThreadId(input.threadId),
75 fromTurnCount,
76 toTurnCount,
77 });
77 return api.orchestration.getTurnDiff(decodedRequest.value.input);
7878 },
79 enabled: !!api && !!input.threadId && hasValidRange,
79 enabled: !!input.threadId && decodedRequest._tag === "Some",
8080 staleTime: Infinity,
8181 retry: (failureCount, error) => {
8282 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/lib/serverReactQuery.ts+3-6
......@@ -1,21 +1,18 @@
1import type { NativeApi } from "@t3tools/contracts";
21import { queryOptions } from "@tanstack/react-query";
2import { ensureNativeApi } from "~/nativeApi";
33
44export const serverQueryKeys = {
55 all: ["server"] as const,
66 config: () => ["server", "config"] as const,
77};
88
9export function serverConfigQueryOptions(api: NativeApi | undefined) {
9export function serverConfigQueryOptions() {
1010 return queryOptions({
1111 queryKey: serverQueryKeys.config(),
1212 queryFn: async () => {
13 if (!api || !api.server || typeof api.server.getConfig !== "function") {
14 throw new Error("Server config is unavailable.");
15 }
13 const api = ensureNativeApi();
1614 return api.server.getConfig();
1715 },
18 enabled: !!api && !!api.server && typeof api.server.getConfig === "function",
1916 staleTime: Infinity,
2017 });
2118}
apps/web/src/model-logic.test.ts deleted-33
......@@ -1,33 +0,0 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
3
4describe("normalizeModelSlug", () => {
5 it("maps 5.3 aliases to canonical codex slug", () => {
6 expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
7 expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
8 });
9
10 it("preserves non-aliased slugs", () => {
11 expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
12 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
13 });
14
15 it("returns null for empty input", () => {
16 expect(normalizeModelSlug("")).toBeNull();
17 expect(normalizeModelSlug(" ")).toBeNull();
18 expect(normalizeModelSlug(null)).toBeNull();
19 expect(normalizeModelSlug(undefined)).toBeNull();
20 });
21});
22
23describe("resolveModelSlug", () => {
24 it("falls back to default for unknown models", () => {
25 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
26 });
27
28 it("resolves supported hard-coded models", () => {
29 expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
30 expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
31 expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2");
32 });
33});
apps/web/src/model-logic.ts deleted-10
......@@ -1,10 +0,0 @@
1export {
2 DEFAULT_MODEL,
3 MODEL_OPTIONS,
4 normalizeModelSlug,
5 resolveModelSlug,
6} from "@t3tools/contracts";
7
8export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
9export type ReasoningEffort = (typeof REASONING_OPTIONS)[number];
10export const DEFAULT_REASONING: ReasoningEffort = "high";
apps/web/src/nativeApi.ts+8
......@@ -16,3 +16,11 @@ export function readNativeApi(): NativeApi | undefined {
1616 cachedApi = createWsNativeApi();
1717 return cachedApi;
1818}
19
20export function ensureNativeApi(): NativeApi {
21 const api = readNativeApi();
22 if (!api) {
23 throw new Error("Native API not found");
24 }
25 return api;
26}
apps/web/src/routes/__root.tsx+4-6
......@@ -9,7 +9,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
99import { APP_DISPLAY_NAME } from "../branding";
1010import { Button } from "../components/ui/button";
1111import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
12import { useNativeApi } from "../hooks/useNativeApi";
12import { readNativeApi } from "../nativeApi";
1313import { useStore } from "../store";
1414import { onServerWelcome } from "../wsNativeApi";
1515import { providerQueryKeys } from "../lib/providerReactQuery";
......@@ -22,9 +22,7 @@ export const Route = createRootRouteWithContext<{
2222});
2323
2424function RootRouteView() {
25 const api = useNativeApi();
26
27 if (!api) {
25 if (!readNativeApi()) {
2826 return (
2927 <div className="flex h-screen flex-col bg-background text-foreground">
3028 <div className="flex flex-1 items-center justify-center">
......@@ -119,11 +117,11 @@ function errorDetails(error: unknown): string {
119117}
120118
121119function EventRouter() {
122 const api = useNativeApi();
123120 const { dispatch } = useStore();
124121 const queryClient = useQueryClient();
125122
126123 useEffect(() => {
124 const api = readNativeApi();
127125 if (!api) return;
128126 let disposed = false;
129127 let latestSequence = 0;
......@@ -177,7 +175,7 @@ function EventRouter() {
177175 unsubDomainEvent();
178176 unsubWelcome();
179177 };
180 }, [api, dispatch, queryClient]);
178 }, [dispatch, queryClient]);
181179
182180 return null;
183181}
apps/web/src/routes/_chat.$threadId.tsx+6-8
......@@ -1,3 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
12import { createFileRoute, useNavigate } from "@tanstack/react-router";
23import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
34
......@@ -133,9 +134,11 @@ const DiffPanelInlineSidebar = (props: {
133134function ChatThreadRouteView() {
134135 const { state } = useStore();
135136 const navigate = useNavigate();
136 const { threadId } = Route.useParams();
137 const threadId = Route.useParams({
138 select: (params) => ThreadId.makeUnsafe(params.threadId),
139 });
137140 const search = Route.useSearch();
138 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
141 const threadExists = state.threads.some((thread) => thread.id === threadId);
139142 const diffOpen = search.diff === "1";
140143 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
141144 const closeDiff = useCallback(() => {
......@@ -159,11 +162,6 @@ function ChatThreadRouteView() {
159162 }, [navigate, threadId]);
160163
161164 useEffect(() => {
162 if (!threadId) {
163 void navigate({ to: "/", replace: true });
164 return;
165 }
166
167165 if (!state.threadsHydrated) {
168166 return;
169167 }
......@@ -174,7 +172,7 @@ function ChatThreadRouteView() {
174172 }
175173 }, [navigate, state.threadsHydrated, threadExists, threadId]);
176174
177 if (!threadId || !state.threadsHydrated || !threadExists) {
175 if (!state.threadsHydrated || !threadExists) {
178176 return null;
179177 }
180178
apps/web/src/routes/_chat.tsx+1-1
......@@ -3,7 +3,7 @@ import { useEffect } from "react";
33
44import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
55import ThreadSidebar from "../components/Sidebar";
6import { SidebarInset, Sidebar, SidebarProvider } from "~/components/ui/sidebar";
6import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";
77
88function ChatRouteLayout() {
99 const navigate = useNavigate();
apps/web/src/session-logic.test.ts+2-34
......@@ -1,11 +1,7 @@
11import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import {
5 derivePendingApprovals,
6 deriveTurnDiffFilesFromUnifiedDiff,
7 deriveWorkLogEntries,
8} from "./session-logic";
4import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic";
95
106function makeActivity(overrides: {
117 id?: string;
......@@ -105,7 +101,7 @@ describe("deriveWorkLogEntries", () => {
105101 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),
106102 ];
107103
108 const entries = deriveWorkLogEntries(activities, "turn-2");
104 const entries = deriveWorkLogEntries(activities, TurnId.makeUnsafe("turn-2"));
109105 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);
110106 });
111107
......@@ -130,31 +126,3 @@ describe("deriveWorkLogEntries", () => {
130126 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
131127 });
132128});
133
134describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
135 it("extracts per-file +/- counts from unified diff", () => {
136 const diff = [
137 "diff --git a/a.txt b/a.txt",
138 "index 111..222 100644",
139 "--- a/a.txt",
140 "+++ b/a.txt",
141 "@@ -1 +1,2 @@",
142 "-old",
143 "+new",
144 "+extra",
145 "diff --git a/b.txt b/b.txt",
146 "index 333..444 100644",
147 "--- a/b.txt",
148 "+++ b/b.txt",
149 "@@ -2,2 +2 @@",
150 "-x",
151 "-y",
152 "+z",
153 ].join("\n");
154
155 expect(deriveTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
156 { path: "a.txt", additions: 2, deletions: 1 },
157 { path: "b.txt", additions: 1, deletions: 2 },
158 ]);
159 });
160});
apps/web/src/session-logic.ts+16-64
......@@ -1,12 +1,11 @@
1import type { OrchestrationThreadActivity, ProviderKind } from "@t3tools/contracts";
1import {
2 ApprovalRequestId,
3 type OrchestrationThreadActivity,
4 type ProviderKind,
5 type TurnId,
6} from "@t3tools/contracts";
27
3import type {
4 ChatMessage,
5 SessionPhase,
6 ThreadSession,
7 TurnDiffFileChange,
8 TurnDiffSummary,
9} from "./types";
8import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";
109
1110export const PROVIDER_OPTIONS: Array<{
1211 value: ProviderKind;
......@@ -26,7 +25,7 @@ export interface WorkLogEntry {
2625}
2726
2827export interface PendingApproval {
29 requestId: string;
28 requestId: ApprovalRequestId;
3029 requestKind: "command" | "file-change";
3130 createdAt: string;
3231 detail?: string;
......@@ -79,7 +78,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
7978export function derivePendingApprovals(
8079 activities: ReadonlyArray<OrchestrationThreadActivity>,
8180): PendingApproval[] {
82 const openByRequestId = new Map<string, PendingApproval>();
81 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
8382 const ordered = [...activities].toSorted((left, right) =>
8483 left.createdAt.localeCompare(right.createdAt),
8584 );
......@@ -89,7 +88,10 @@ export function derivePendingApprovals(
8988 activity.payload && typeof activity.payload === "object"
9089 ? (activity.payload as Record<string, unknown>)
9190 : null;
92 const requestId = payload && typeof payload.requestId === "string" ? payload.requestId : null;
91 const requestId =
92 payload && typeof payload.requestId === "string"
93 ? ApprovalRequestId.makeUnsafe(payload.requestId)
94 : null;
9395 const requestKind =
9496 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")
9597 ? payload.requestKind
......@@ -118,7 +120,7 @@ export function derivePendingApprovals(
118120
119121export function deriveWorkLogEntries(
120122 activities: ReadonlyArray<OrchestrationThreadActivity>,
121 latestTurnId: string | undefined,
123 latestTurnId: TurnId | undefined,
122124): WorkLogEntry[] {
123125 const ordered = [...activities].toSorted((left, right) =>
124126 left.createdAt.localeCompare(right.createdAt),
......@@ -163,55 +165,11 @@ export function deriveTimelineEntries(
163165 return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
164166}
165167
166export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {
167 const normalized = diff.replace(/\r\n/g, "\n");
168 const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)];
169 if (headerMatches.length === 0) {
170 return [];
171 }
172
173 const files: TurnDiffFileChange[] = [];
174 for (let index = 0; index < headerMatches.length; index += 1) {
175 const header = headerMatches[index];
176 if (!header) continue;
177 const start = header.index ?? 0;
178 const nextStart = headerMatches[index + 1]?.index ?? normalized.length;
179 const segment = normalized.slice(start, nextStart);
180 const headerLine = header[0];
181 if (!headerLine) continue;
182
183 const match = headerLine.match(/^diff --git a\/(.+?) b\/(.+)$/);
184 const filePath = match?.[2] ?? match?.[1];
185 if (!filePath) continue;
186
187 let additions = 0;
188 let deletions = 0;
189 for (const line of segment.split("\n")) {
190 if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
191 if (line.startsWith("+")) {
192 additions += 1;
193 continue;
194 }
195 if (line.startsWith("-")) {
196 deletions += 1;
197 }
198 }
199
200 files.push({
201 path: filePath,
202 additions,
203 deletions,
204 });
205 }
206
207 return files.toSorted((left, right) => left.path.localeCompare(right.path));
208}
209
210168export function inferCheckpointTurnCountByTurnId(
211169 summaries: TurnDiffSummary[],
212): Record<string, number> {
170): Record<TurnId, number> {
213171 const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt));
214 const result: Record<string, number> = {};
172 const result: Record<TurnId, number> = {};
215173 for (let index = 0; index < sorted.length; index += 1) {
216174 const summary = sorted[index];
217175 if (!summary) continue;
......@@ -220,12 +178,6 @@ export function inferCheckpointTurnCountByTurnId(
220178 return result;
221179}
222180
223export function deriveTurnDiffSummaries(
224 _events: ReadonlyArray<OrchestrationThreadActivity>,
225): TurnDiffSummary[] {
226 return [];
227}
228
229181export function derivePhase(session: ThreadSession | null): SessionPhase {
230182 if (!session || session.status === "closed") return "disconnected";
231183 if (session.status === "connecting") return "connecting";
apps/web/src/store.ts+23-16
......@@ -8,15 +8,20 @@ import {
88 useReducer,
99} from "react";
1010
11import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts";
12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
11import {
12 DEFAULT_MODEL,
13 ProviderSessionId,
14 ThreadId,
15 type OrchestrationReadModel,
16 type OrchestrationSessionStatus,
17 resolveModelSlug,
18} from "@t3tools/contracts";
1319import {
1420 DEFAULT_THREAD_TERMINAL_HEIGHT,
1521 DEFAULT_THREAD_TERMINAL_ID,
1622 DEFAULT_RUNTIME_MODE,
1723 MAX_THREAD_TERMINAL_COUNT,
1824 type ChatMessage,
19 type ProjectScript,
2025 type Project,
2126 type RuntimeMode,
2227 type Thread,
......@@ -27,19 +32,19 @@ import {
2732
2833type Action =
2934 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
30 | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }
31 | { type: "TOGGLE_PROJECT"; projectId: string }
32 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
33 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
34 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
35 | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }
36 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
37 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
38 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
39 | { type: "SET_ERROR"; threadId: string; error: string | null }
35 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
36 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
37 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
38 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
39 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
40 | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
41 | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
42 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string }
43 | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
44 | { type: "SET_ERROR"; threadId: ThreadId; error: string | null }
4045 | {
4146 type: "SET_THREAD_BRANCH";
42 threadId: string;
47 threadId: ThreadId;
4348 branch: string | null;
4449 worktreePath: string | null;
4550 }
......@@ -127,7 +132,7 @@ function persistState(state: AppState): void {
127132
128133function updateThread(
129134 threads: Thread[],
130 threadId: string,
135 threadId: ThreadId,
131136 updater: (t: Thread) => Thread,
132137): Thread[] {
133138 return threads.map((t) => (t.id === threadId ? updater(t) : t));
......@@ -415,7 +420,9 @@ export function reducer(state: AppState, action: Action): AppState {
415420 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
416421 session: thread.session
417422 ? {
418 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,
423 sessionId:
424 thread.session.providerSessionId ??
425 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
419426 provider: toLegacyProvider(thread.session.providerName),
420427 status: toLegacySessionStatus(thread.session.status),
421428 orchestrationStatus: thread.session.status,
apps/web/src/threadFactory.ts deleted-42
......@@ -1,42 +0,0 @@
1import { DEFAULT_MODEL } from "./model-logic";
2import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
3
4interface CreateThreadOptions {
5 branch?: string | null;
6 createdAt?: string;
7 model?: string;
8 title?: string;
9 worktreePath?: string | null;
10}
11
12export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread {
13 const createdAt = options.createdAt ?? new Date().toISOString();
14
15 return {
16 id: crypto.randomUUID(),
17 codexThreadId: null,
18 projectId,
19 title: options.title ?? "New thread",
20 model: options.model ?? DEFAULT_MODEL,
21 terminalOpen: false,
22 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
23 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
24 runningTerminalIds: [],
25 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
26 terminalGroups: [
27 {
28 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
29 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
30 },
31 ],
32 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
33 session: null,
34 messages: [],
35 turnDiffSummaries: [],
36 activities: [],
37 error: null,
38 createdAt,
39 branch: options.branch ?? null,
40 worktreePath: options.worktreePath ?? null,
41 };
42}
apps/web/src/types.ts+21-13
......@@ -2,6 +2,14 @@ import type {
22 OrchestrationSessionStatus,
33 OrchestrationThreadActivity,
44 ProjectScript as ContractProjectScript,
5 ThreadId,
6 ProjectId,
7 TurnId,
8 MessageId,
9 CheckpointRef,
10 ProviderThreadId,
11 ProviderSessionId,
12 ProviderKind,
513} from "@t3tools/contracts";
614
715export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
......@@ -29,7 +37,7 @@ export interface ChatImageAttachment {
2937export type ChatAttachment = ChatImageAttachment;
3038
3139export interface ChatMessage {
32 id: string;
40 id: MessageId;
3341 role: "user" | "assistant" | "system";
3442 text: string;
3543 attachments?: ChatAttachment[];
......@@ -46,17 +54,17 @@ export interface TurnDiffFileChange {
4654}
4755
4856export interface TurnDiffSummary {
49 turnId: string;
57 turnId: TurnId;
5058 completedAt: string;
5159 status?: string | undefined;
5260 files: TurnDiffFileChange[];
53 checkpointRef?: string | undefined;
54 assistantMessageId?: string | undefined;
61 checkpointRef?: CheckpointRef | undefined;
62 assistantMessageId?: MessageId | undefined;
5563 checkpointTurnCount?: number | undefined;
5664}
5765
5866export interface Project {
59 id: string;
67 id: ProjectId;
6068 name: string;
6169 cwd: string;
6270 model: string;
......@@ -65,9 +73,9 @@ export interface Project {
6573}
6674
6775export interface Thread {
68 id: string;
69 codexThreadId: string | null;
70 projectId: string;
76 id: ThreadId;
77 codexThreadId: ProviderThreadId | null;
78 projectId: ProjectId;
7179 title: string;
7280 model: string;
7381 terminalOpen: boolean;
......@@ -81,7 +89,7 @@ export interface Thread {
8189 messages: ChatMessage[];
8290 error: string | null;
8391 createdAt: string;
84 latestTurnId?: string | undefined;
92 latestTurnId?: TurnId | undefined;
8593 latestTurnStartedAt?: string | undefined;
8694 latestTurnCompletedAt?: string | undefined;
8795 latestTurnDurationMs?: number | undefined;
......@@ -93,11 +101,11 @@ export interface Thread {
93101}
94102
95103export interface ThreadSession {
96 sessionId: string;
97 provider: "codex" | "claudeCode";
104 sessionId: ProviderSessionId;
105 provider: ProviderKind;
98106 status: SessionPhase | "error" | "closed";
99 threadId: string | null;
100 activeTurnId?: string | undefined;
107 threadId: ProviderThreadId | null;
108 activeTurnId?: TurnId | undefined;
101109 createdAt: string;
102110 updatedAt: string;
103111 lastError?: string;
apps/web/src/worktreeCleanup.test.ts+24-11
......@@ -1,3 +1,4 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23
34import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
......@@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
56
67function makeThread(overrides: Partial<Thread> = {}): Thread {
78 return {
8 id: "thread-1",
9 id: ThreadId.makeUnsafe("thread-1"),
910 codexThreadId: null,
10 projectId: "project-1",
11 projectId: ProjectId.makeUnsafe("project-1"),
1112 title: "Thread",
1213 model: "gpt-5.3-codex",
1314 terminalOpen: false,
......@@ -36,37 +37,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3637
3738describe("getOrphanedWorktreePathForThread", () => {
3839 it("returns null when the target thread does not exist", () => {
39 const result = getOrphanedWorktreePathForThread([], "missing-thread");
40 const result = getOrphanedWorktreePathForThread([], ThreadId.makeUnsafe("missing-thread"));
4041 expect(result).toBeNull();
4142 });
4243
4344 it("returns null when the target thread has no worktree", () => {
4445 const threads = [makeThread()];
45 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
46 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
4647 expect(result).toBeNull();
4748 });
4849
4950 it("returns the path when no other thread links to that worktree", () => {
5051 const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })];
51 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
52 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
5253 expect(result).toBe("/tmp/repo/worktrees/feature-a");
5354 });
5455
5556 it("returns null when another thread links to the same worktree", () => {
5657 const threads = [
57 makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }),
58 makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }),
58 makeThread({
59 id: ThreadId.makeUnsafe("thread-1"),
60 worktreePath: "/tmp/repo/worktrees/feature-a",
61 }),
62 makeThread({
63 id: ThreadId.makeUnsafe("thread-2"),
64 worktreePath: "/tmp/repo/worktrees/feature-a",
65 }),
5966 ];
60 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
67 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
6168 expect(result).toBeNull();
6269 });
6370
6471 it("ignores threads linked to different worktrees", () => {
6572 const threads = [
66 makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }),
67 makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }),
73 makeThread({
74 id: ThreadId.makeUnsafe("thread-1"),
75 worktreePath: "/tmp/repo/worktrees/feature-a",
76 }),
77 makeThread({
78 id: ThreadId.makeUnsafe("thread-2"),
79 worktreePath: "/tmp/repo/worktrees/feature-b",
80 }),
6881 ];
69 const result = getOrphanedWorktreePathForThread(threads, "thread-1");
82 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
7083 expect(result).toBe("/tmp/repo/worktrees/feature-a");
7184 });
7285});
apps/web/src/worktreeCleanup.ts+1-1
......@@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null {
1010
1111export function getOrphanedWorktreePathForThread(
1212 threads: readonly Thread[],
13 threadId: string,
13 threadId: Thread["id"],
1414): string | null {
1515 const targetThread = threads.find((thread) => thread.id === threadId);
1616 if (!targetThread) {
apps/web/src/wsTransport.ts+1-6
......@@ -13,12 +13,7 @@ const REQUEST_TIMEOUT_MS = 60_000;
1313const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
1414const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse));
1515const isWsPushEnvelope = Schema.is(WsPush);
16
17function isWebSocketResponseEnvelope(
18 message: typeof WsResponse.Type,
19): message is WebSocketResponse {
20 return message instanceof WebSocketResponse;
21}
16const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
2217
2318interface WsRequestEnvelope {
2419 id: string;
packages/contracts/src/editor.ts+3-2
......@@ -8,7 +8,8 @@ export const EDITORS = [
88export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
99export type EditorId = typeof EditorId.Type;
1010
11export class OpenInEditorInput extends Schema.Class<OpenInEditorInput>("OpenInEditorInput")({
11export const OpenInEditorInput = Schema.Struct({
1212 cwd: Schema.String,
1313 editor: EditorId,
14}) {}
14});
15export type OpenInEditorInput = typeof OpenInEditorInput.Type;
packages/contracts/src/git.ts+48-48
......@@ -18,78 +18,79 @@ export const GitPrStepStatus = Schema.Literals([
1818 "skipped_not_requested",
1919]);
2020
21export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({
21export const GitBranch = Schema.Struct({
2222 name: TrimmedNonEmptyString,
2323 current: Schema.Boolean,
2424 isDefault: Schema.Boolean,
2525 worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr),
26}) {}
26});
27export type GitBranch = typeof GitBranch.Type;
2728
28export class GitWorktree extends Schema.Class<GitWorktree>("GitWorktree")({
29export const GitWorktree = Schema.Struct({
2930 path: TrimmedNonEmptyString,
3031 branch: TrimmedNonEmptyString,
31}) {}
32});
33export type GitWorktree = typeof GitWorktree.Type;
3234
3335// RPC Inputs
3436
35export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({
37export const GitStatusInput = Schema.Struct({
3638 cwd: TrimmedNonEmptyString,
37}) {}
39});
40export type GitStatusInput = typeof GitStatusInput.Type;
3841
39export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({
42export const GitPullInput = Schema.Struct({
4043 cwd: TrimmedNonEmptyString,
41}) {}
44});
45export type GitPullInput = typeof GitPullInput.Type;
4246
43export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>(
44 "GitRunStackedActionInput",
45)({
47export const GitRunStackedActionInput = Schema.Struct({
4648 cwd: TrimmedNonEmptyString,
4749 action: GitStackedAction,
4850 commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))),
49}) {}
51});
52export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
5053
51export class GitListBranchesInput extends Schema.Class<GitListBranchesInput>(
52 "GitListBranchesInput",
53)({
54export const GitListBranchesInput = Schema.Struct({
5455 cwd: TrimmedNonEmptyString,
55}) {}
56});
57export type GitListBranchesInput = typeof GitListBranchesInput.Type;
5658
57export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>(
58 "GitCreateWorktreeInput",
59)({
59export const GitCreateWorktreeInput = Schema.Struct({
6060 cwd: TrimmedNonEmptyString,
6161 branch: TrimmedNonEmptyString,
6262 newBranch: TrimmedNonEmptyString,
6363 path: TrimmedNonEmptyString.pipe(Schema.NullOr),
64}) {}
64});
65export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
6566
66export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>(
67 "GitRemoveWorktreeInput",
68)({
67export const GitRemoveWorktreeInput = Schema.Struct({
6968 cwd: TrimmedNonEmptyString,
7069 path: TrimmedNonEmptyString,
7170 force: Schema.optional(Schema.Boolean),
72}) {}
71});
72export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
7373
74export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>(
75 "GitCreateBranchInput",
76)({
74export const GitCreateBranchInput = Schema.Struct({
7775 cwd: TrimmedNonEmptyString,
7876 branch: TrimmedNonEmptyString,
79}) {}
77});
78export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
8079
81export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({
80export const GitCheckoutInput = Schema.Struct({
8281 cwd: TrimmedNonEmptyString,
8382 branch: TrimmedNonEmptyString,
84}) {}
83});
84export type GitCheckoutInput = typeof GitCheckoutInput.Type;
8585
86export class GitInitInput extends Schema.Class<GitInitInput>("GitInitInput")({
86export const GitInitInput = Schema.Struct({
8787 cwd: TrimmedNonEmptyString,
88}) {}
88});
89export type GitInitInput = typeof GitInitInput.Type;
8990
9091// RPC Results
9192
92export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusResult")({
93export const GitStatusResult = Schema.Struct({
9394 branch: TrimmedNonEmptyString.pipe(Schema.NullOr),
9495 hasWorkingTreeChanges: Schema.Boolean,
9596 workingTree: Schema.Struct({
......@@ -113,24 +114,21 @@ export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusRes
113114 baseBranch: TrimmedNonEmptyString,
114115 headBranch: TrimmedNonEmptyString,
115116 }).pipe(Schema.NullOr),
116}) {}
117});
118export type GitStatusResult = typeof GitStatusResult.Type;
117119
118export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>(
119 "GitListBranchesResult",
120)({
120export const GitListBranchesResult = Schema.Struct({
121121 branches: Schema.Array(GitBranch),
122122 isRepo: Schema.Boolean,
123}) {}
123});
124export type GitListBranchesResult = typeof GitListBranchesResult.Type;
124125
125export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>(
126 "GitCreateWorktreeResult",
127)({
126export const GitCreateWorktreeResult = Schema.Struct({
128127 worktree: GitWorktree,
129}) {}
128});
129export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
130130
131export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>(
132 "GitRunStackedActionResult",
133)({
131export const GitRunStackedActionResult = Schema.Struct({
134132 action: GitStackedAction,
135133 commit: Schema.Struct({
136134 status: GitCommitStepStatus,
......@@ -151,10 +149,12 @@ export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionR
151149 headBranch: Schema.optional(TrimmedNonEmptyString),
152150 title: Schema.optional(TrimmedNonEmptyString),
153151 }),
154}) {}
152});
153export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
155154
156export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({
155export const GitPullResult = Schema.Struct({
157156 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
158157 branch: TrimmedNonEmptyString,
159158 upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr),
160}) {}
159});
160export type GitPullResult = typeof GitPullResult.Type;
packages/contracts/src/keybindings.ts+12-14
......@@ -44,25 +44,27 @@ export const KeybindingWhen = Schema.Trim.check(
4444 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
4545);
4646export type KeybindingWhen = typeof KeybindingWhen.Type;
47export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({
47export const KeybindingRule = Schema.Struct({
4848 key: KeybindingValue,
4949 command: KeybindingCommand,
5050 when: Schema.optional(KeybindingWhen),
51}) {}
51});
52export type KeybindingRule = typeof KeybindingRule.Type;
5253
5354export const KeybindingsConfig = Schema.Array(KeybindingRule).check(
5455 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
5556);
5657export type KeybindingsConfig = typeof KeybindingsConfig.Type;
5758
58export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({
59export const KeybindingShortcut = Schema.Struct({
5960 key: KeybindingValue,
6061 metaKey: Schema.Boolean,
6162 ctrlKey: Schema.Boolean,
6263 shiftKey: Schema.Boolean,
6364 altKey: Schema.Boolean,
6465 modKey: Schema.Boolean,
65}) {}
66});
67export type KeybindingShortcut = typeof KeybindingShortcut.Type;
6668
6769export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([
6870 Schema.Struct({
......@@ -90,16 +92,12 @@ export type KeybindingWhenNode =
9092 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
9193 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
9294
93export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(
94 "ResolvedKeybindingRule",
95)(
96 {
97 command: KeybindingCommand,
98 shortcut: KeybindingShortcut,
99 whenAst: Schema.optional(KeybindingWhenNode),
100 },
101 { parseOptions: { onExcessProperty: "ignore" } },
102) {}
95export const ResolvedKeybindingRule = Schema.Struct({
96 command: KeybindingCommand,
97 shortcut: KeybindingShortcut,
98 whenAst: Schema.optional(KeybindingWhenNode),
99}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
100export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;
103101
104102export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
105103 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
packages/contracts/src/model.ts+4
......@@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug {
3737
3838 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;
3939}
40
41export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
42export type ReasoningEffort = (typeof REASONING_OPTIONS)[number];
43export const DEFAULT_REASONING: ReasoningEffort = "high";
packages/contracts/src/orchestration.test.ts created+46
......@@ -0,0 +1,46 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3
4import { OrchestrationGetTurnDiffInput, ThreadTurnDiff } from "./orchestration";
5
6const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
7const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
8
9it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
10 Effect.gen(function* () {
11 const parsed = yield* decodeTurnDiffInput({
12 threadId: "thread-1",
13 fromTurnCount: 1,
14 toTurnCount: 2,
15 });
16 assert.strictEqual(parsed.fromTurnCount, 1);
17 assert.strictEqual(parsed.toTurnCount, 2);
18 }),
19);
20
21it.effect("rejects turn diff input when fromTurnCount > toTurnCount", () =>
22 Effect.gen(function* () {
23 const result = yield* Effect.exit(
24 decodeTurnDiffInput({
25 threadId: "thread-1",
26 fromTurnCount: 3,
27 toTurnCount: 2,
28 }),
29 );
30 assert.strictEqual(result._tag, "Failure");
31 }),
32);
33
34it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () =>
35 Effect.gen(function* () {
36 const result = yield* Effect.exit(
37 decodeThreadTurnDiff({
38 threadId: "thread-1",
39 fromTurnCount: 3,
40 toTurnCount: 2,
41 diff: "patch",
42 }),
43 );
44 assert.strictEqual(result._tag, "Failure");
45 }),
46);
packages/contracts/src/orchestration.ts+22-11
......@@ -1,4 +1,4 @@
1import { Schema } from "effect";
1import { Option, Schema, SchemaIssue, Struct } from "effect";
22
33export const ORCHESTRATION_WS_METHODS = {
44 getSnapshot: "orchestration.getSnapshot",
......@@ -777,13 +777,24 @@ export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandRecei
777777export const TurnCountRange = Schema.Struct({
778778 fromTurnCount: NonNegativeInt,
779779 toTurnCount: NonNegativeInt,
780});
780}).check(
781 Schema.makeFilter(
782 (input) =>
783 input.fromTurnCount <= input.toTurnCount ||
784 new SchemaIssue.InvalidValue(Option.some(input.fromTurnCount), {
785 message: "fromTurnCount must be less than or equal to toTurnCount",
786 }),
787 { identifier: "OrchestrationTurnDiffRange" },
788 ),
789);
781790
782export const ThreadTurnDiff = Schema.Struct({
783 threadId: ThreadId,
784 ...TurnCountRange.fields,
785 diff: Schema.String,
786});
791export const ThreadTurnDiff = TurnCountRange.mapFields(
792 Struct.assign({
793 threadId: ThreadId,
794 diff: Schema.String,
795 }),
796 { unsafePreserveChecks: true },
797);
787798
788799export const ProviderSessionRuntimeStatus = Schema.Literals([
789800 "starting",
......@@ -829,10 +840,10 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput
829840export const OrchestrationGetSnapshotResult = OrchestrationReadModel;
830841export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type;
831842
832export const OrchestrationGetTurnDiffInput = Schema.Struct({
833 threadId: ThreadId,
834 ...TurnCountRange.fields,
835});
843export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(
844 Struct.assign({ threadId: ThreadId }),
845 { unsafePreserveChecks: true },
846);
836847export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;
837848
838849export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
packages/contracts/src/project.ts+9-10
......@@ -3,28 +3,27 @@ import { Schema } from "effect";
33export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
44const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
55
6export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>(
7 "ProjectSearchEntriesInput",
8)({
6export const ProjectSearchEntriesInput = Schema.Struct({
97 cwd: TrimmedNonEmptyString,
108 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
119 limit: Schema.Int.check(
1210 Schema.isGreaterThanOrEqualTo(1),
1311 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),
1412 ),
15}) {}
13});
14export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
1615
1716export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
1817
19export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({
18export const ProjectEntry = Schema.Struct({
2019 path: TrimmedNonEmptyString,
2120 kind: ProjectEntryKind,
2221 parentPath: Schema.optional(TrimmedNonEmptyString),
23}) {}
22});
23export type ProjectEntry = typeof ProjectEntry.Type;
2424
25export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>(
26 "ProjectSearchEntriesResult",
27)({
25export const ProjectSearchEntriesResult = Schema.Struct({
2826 entries: Schema.Array(ProjectEntry),
2927 truncated: Schema.Boolean,
30}) {}
28});
29export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type;
packages/contracts/src/ws.test.ts created+39
......@@ -0,0 +1,39 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";
3
4import { ORCHESTRATION_WS_METHODS } from "./orchestration";
5import { WebSocketRequest } from "./ws";
6
7const decodeWebSocketRequest = Schema.decodeUnknownEffect(WebSocketRequest);
8
9it.effect("accepts getTurnDiff requests when fromTurnCount <= toTurnCount", () =>
10 Effect.gen(function* () {
11 const parsed = yield* decodeWebSocketRequest({
12 id: "req-1",
13 body: {
14 _tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
15 threadId: "thread-1",
16 fromTurnCount: 1,
17 toTurnCount: 2,
18 },
19 });
20 assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff);
21 }),
22);
23
24it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () =>
25 Effect.gen(function* () {
26 const result = yield* Effect.exit(
27 decodeWebSocketRequest({
28 id: "req-1",
29 body: {
30 _tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
31 threadId: "thread-1",
32 fromTurnCount: 3,
33 toTurnCount: 2,
34 },
35 }),
36 );
37 assert.strictEqual(result._tag, "Failure");
38 }),
39);
packages/contracts/src/ws.ts+49-33
......@@ -1,4 +1,4 @@
1import { Schema } from "effect";
1import { Schema, Struct } from "effect";
22
33import {
44 ClientOrchestrationCommand,
......@@ -76,43 +76,56 @@ export const WS_CHANNELS = {
7676
7777// -- Tagged Union of all request body schemas ─────────────────────────
7878
79export const WebSocketRequestBody = Schema.TaggedUnion({
79const tagRequestBody = <const Tag extends string, const Fields extends Schema.Struct.Fields>(
80 tag: Tag,
81 schema: Schema.Struct<Fields>,
82) =>
83 schema.mapFields(
84 Struct.assign({ _tag: Schema.tag(tag) }),
85 // PreserveChecks is safe here. No existing schema should have checks depending on the tag
86 { unsafePreserveChecks: true },
87 );
88
89export const WebSocketRequestBody = Schema.Union([
8090 // Orchestration methods
81 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },
82 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,
83 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,
84 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields,
85 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,
91 tagRequestBody(
92 ORCHESTRATION_WS_METHODS.dispatchCommand,
93 Schema.Struct({ command: ClientOrchestrationCommand }),
94 ),
95 tagRequestBody(ORCHESTRATION_WS_METHODS.getSnapshot, OrchestrationGetSnapshotInput),
96 tagRequestBody(ORCHESTRATION_WS_METHODS.getTurnDiff, OrchestrationGetTurnDiffInput),
97 tagRequestBody(ORCHESTRATION_WS_METHODS.getFullThreadDiff, OrchestrationGetFullThreadDiffInput),
98 tagRequestBody(ORCHESTRATION_WS_METHODS.replayEvents, OrchestrationReplayEventsInput),
8699
87100 // Project Search
88 [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields,
101 tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput),
89102
90103 // Shell methods
91 [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields,
104 tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),
92105
93106 // Git methods
94 [WS_METHODS.gitPull]: GitPullInput.fields,
95 [WS_METHODS.gitStatus]: GitStatusInput.fields,
96 [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields,
97 [WS_METHODS.gitListBranches]: GitListBranchesInput.fields,
98 [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields,
99 [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields,
100 [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields,
101 [WS_METHODS.gitCheckout]: GitCheckoutInput.fields,
102 [WS_METHODS.gitInit]: GitInitInput.fields,
107 tagRequestBody(WS_METHODS.gitPull, GitPullInput),
108 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
109 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
110 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
111 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
112 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
113 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
114 tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),
115 tagRequestBody(WS_METHODS.gitInit, GitInitInput),
103116
104117 // Terminal methods
105 [WS_METHODS.terminalOpen]: TerminalOpenInput.fields,
106 [WS_METHODS.terminalWrite]: TerminalWriteInput.fields,
107 [WS_METHODS.terminalResize]: TerminalResizeInput.fields,
108 [WS_METHODS.terminalClear]: TerminalClearInput.fields,
109 [WS_METHODS.terminalRestart]: TerminalRestartInput.fields,
110 [WS_METHODS.terminalClose]: TerminalCloseInput.fields,
118 tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),
119 tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput),
120 tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput),
121 tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput),
122 tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput),
123 tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput),
111124
112125 // Server meta
113 [WS_METHODS.serverGetConfig]: {},
114 [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields,
115});
126 tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})),
127 tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule),
128]);
116129
117130export const WebSocketRequest = Schema.Struct({
118131 id: Schema.String,
......@@ -120,7 +133,7 @@ export const WebSocketRequest = Schema.Struct({
120133});
121134export type WebSocketRequest = typeof WebSocketRequest.Type;
122135
123export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocketResponse")({
136export const WebSocketResponse = Schema.Struct({
124137 id: Schema.String,
125138 result: Schema.optional(Schema.Unknown),
126139 error: Schema.optional(
......@@ -128,13 +141,15 @@ export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocke
128141 message: Schema.String,
129142 }),
130143 ),
131}) {}
144});
145export type WebSocketResponse = typeof WebSocketResponse.Type;
132146
133export class WsPush extends Schema.Class<WsPush>("WsPushEvent")({
147export const WsPush = Schema.Struct({
134148 type: Schema.Literal("push"),
135149 channel: Schema.String,
136150 data: Schema.Unknown,
137}) {}
151});
152export type WsPush = typeof WsPush.Type;
138153
139154// ── Union of all server → client messages ─────────────────────────────
140155
......@@ -143,7 +158,8 @@ export type WsResponse = typeof WsResponse.Type;
143158
144159// ── Server welcome payload ───────────────────────────────────────────
145160
146export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({
161export const WsWelcomePayload = Schema.Struct({
147162 cwd: Schema.String,
148163 projectName: Schema.String,
149}) {}
164});
165export type WsWelcomePayload = typeof WsWelcomePayload.Type;