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";...@@ -6,7 +6,6 @@ import { execFileSync } from "node:child_process";
6import { NodeServices } from "@effect/platform-node";6import { NodeServices } from "@effect/platform-node";
7import {7import {
8 ApprovalRequestId,8 ApprovalRequestId,
9 CheckpointRef,
10 type OrchestrationEvent,9 type OrchestrationEvent,
11 type OrchestrationThread,10 type OrchestrationThread,
12} from "@t3tools/contracts";11} from "@t3tools/contracts";
...@@ -63,11 +62,6 @@ function initializeGitWorkspace(cwd: string) {...@@ -63,11 +62,6 @@ function initializeGitWorkspace(cwd: string) {
63 runGit(cwd, ["commit", "-m", "Initial"]);62 runGit(cwd, ["commit", "-m", "Initial"]);
64}63}
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
71export function gitRefExists(cwd: string, ref: string): boolean {65export function gitRefExists(cwd: string, ref: string): boolean {
72 try {66 try {
73 runGit(cwd, ["show-ref", "--verify", "--quiet", ref]);67 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";...@@ -17,17 +17,15 @@ import { Effect, Option } from "effect";
1717
18import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";18import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
19import {19import {
20 checkpointRefForTurn,
21 gitRefExists,20 gitRefExists,
22 gitShowFileAtRef,21 gitShowFileAtRef,
23 makeOrchestrationIntegrationHarness,22 makeOrchestrationIntegrationHarness,
24 type OrchestrationIntegrationHarness,23 type OrchestrationIntegrationHarness,
25} from "./OrchestrationEngineHarness.integration.ts";24} from "./OrchestrationEngineHarness.integration.ts";
25import { checkpointRefForThreadTurn } from "../src/checkpointing/Refs.ts";
2626
27const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
28const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);27const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
29const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);28const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
30const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
31const asEventId = (value: string): EventId => EventId.makeUnsafe(value);29const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
32const asProviderSessionId = (value: string): ProviderSessionId =>30const asProviderSessionId = (value: string): ProviderSessionId =>
33 ProviderSessionId.makeUnsafe(value);31 ProviderSessionId.makeUnsafe(value);
...@@ -37,7 +35,7 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>...@@ -37,7 +35,7 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
37 ApprovalRequestId.makeUnsafe(value);35 ApprovalRequestId.makeUnsafe(value);
3836
39const PROJECT_ID = asProjectId("project-1");37const PROJECT_ID = asProjectId("project-1");
40const THREAD_ID = asThreadId("thread-1");38const THREAD_ID = ThreadId.makeUnsafe("thread-1");
41const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");39const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
42const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
43const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
...@@ -95,7 +93,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>...@@ -95,7 +93,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
9593
96 yield* harness.engine.dispatch({94 yield* harness.engine.dispatch({
97 type: "project.create",95 type: "project.create",
98 commandId: asCommandId("cmd-project-create"),96 commandId: CommandId.makeUnsafe("cmd-project-create"),
99 projectId: PROJECT_ID,97 projectId: PROJECT_ID,
100 title: "Integration Project",98 title: "Integration Project",
101 workspaceRoot: harness.workspaceDir,99 workspaceRoot: harness.workspaceDir,
...@@ -105,7 +103,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>...@@ -105,7 +103,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
105103
106 yield* harness.engine.dispatch({104 yield* harness.engine.dispatch({
107 type: "thread.create",105 type: "thread.create",
108 commandId: asCommandId("cmd-thread-create"),106 commandId: CommandId.makeUnsafe("cmd-thread-create"),
109 threadId: THREAD_ID,107 threadId: THREAD_ID,
110 projectId: PROJECT_ID,108 projectId: PROJECT_ID,
111 title: "Integration Thread",109 title: "Integration Thread",
...@@ -124,7 +122,7 @@ const startTurn = (input: {...@@ -124,7 +122,7 @@ const startTurn = (input: {
124}) =>122}) =>
125 input.harness.engine.dispatch({123 input.harness.engine.dispatch({
126 type: "thread.turn.start",124 type: "thread.turn.start",
127 commandId: asCommandId(input.commandId),125 commandId: CommandId.makeUnsafe(input.commandId),
128 threadId: THREAD_ID,126 threadId: THREAD_ID,
129 message: {127 message: {
130 messageId: asMessageId(input.messageId),128 messageId: asMessageId(input.messageId),
...@@ -195,8 +193,8 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +...@@ -195,8 +193,8 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
195193
196 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed");194 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed");
197195
198 const ref0 = checkpointRefForTurn(THREAD_ID, 0);196 const ref0 = checkpointRefForThreadTurn(THREAD_ID, 0);
199 const ref1 = checkpointRefForTurn(THREAD_ID, 1);197 const ref1 = checkpointRefForThreadTurn(THREAD_ID, 1);
200 assert.equal(gitRefExists(harness.workspaceDir, ref0), true);198 assert.equal(gitRefExists(harness.workspaceDir, ref0), true);
201 assert.equal(gitRefExists(harness.workspaceDir, ref1), true);199 assert.equal(gitRefExists(harness.workspaceDir, ref1), true);
202 assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n");200 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", () =>...@@ -335,26 +333,34 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
335333
336 const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({334 const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({
337 cwd: harness.workspaceDir,335 cwd: harness.workspaceDir,
338 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1),336 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 1),
339 toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),337 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
340 fallbackFromToHead: false,338 fallbackFromToHead: false,
341 });339 });
342 assert.equal(incrementalDiff.includes("README.md"), true);340 assert.equal(incrementalDiff.includes("README.md"), true);
343341
344 const fullDiff = yield* harness.checkpointStore.diffCheckpoints({342 const fullDiff = yield* harness.checkpointStore.diffCheckpoints({
345 cwd: harness.workspaceDir,343 cwd: harness.workspaceDir,
346 fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 0),344 fromCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 0),
347 toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),345 toCheckpointRef: checkpointRefForThreadTurn(THREAD_ID, 2),
348 fallbackFromToHead: false,346 fallbackFromToHead: false,
349 });347 });
350 assert.equal(fullDiff.includes("README.md"), true);348 assert.equal(fullDiff.includes("README.md"), true);
351349
352 assert.equal(350 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 ),
354 "v2\n",356 "v2\n",
355 );357 );
356 assert.equal(358 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 ),
358 "v3\n",364 "v3\n",
359 );365 );
360 }),366 }),
...@@ -416,7 +422,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons...@@ -416,7 +422,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons
416422
417 yield* harness.engine.dispatch({423 yield* harness.engine.dispatch({
418 type: "thread.approval.respond",424 type: "thread.approval.respond",
419 commandId: asCommandId("cmd-approval-respond"),425 commandId: CommandId.makeUnsafe("cmd-approval-respond"),
420 threadId: THREAD_ID,426 threadId: THREAD_ID,
421 requestId: APPROVAL_REQUEST_ID,427 requestId: APPROVAL_REQUEST_ID,
422 decision: "accept",428 decision: "accept",
...@@ -507,7 +513,10 @@ it.live("records failed turn runtime state and checkpoint status as error", () =...@@ -507,7 +513,10 @@ it.live("records failed turn runtime state and checkpoint status as error", () =
507 if (Option.isSome(checkpointRow)) {513 if (Option.isSome(checkpointRow)) {
508 assert.equal(checkpointRow.value.status, "error");514 assert.equal(checkpointRow.value.status, "error");
509 }515 }
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 );
511 }),520 }),
512 ),521 ),
513);522);
...@@ -644,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -644,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
644653
645 yield* harness.engine.dispatch({654 yield* harness.engine.dispatch({
646 type: "thread.checkpoint.revert",655 type: "thread.checkpoint.revert",
647 commandId: asCommandId("cmd-checkpoint-revert"),656 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"),
648 threadId: THREAD_ID,657 threadId: THREAD_ID,
649 turnCount: 1,658 turnCount: 1,
650 createdAt: nowIso(),659 createdAt: nowIso(),
...@@ -681,7 +690,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git...@@ -681,7 +690,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
681 true,690 true,
682 );691 );
683 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");692 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 );
685 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);697 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
686698
687 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({699 const checkpointRows = yield* harness.checkpointRepository.listByThreadId({
...@@ -701,7 +713,7 @@ it.live(...@@ -701,7 +713,7 @@ it.live(
701713
702 yield* harness.engine.dispatch({714 yield* harness.engine.dispatch({
703 type: "thread.checkpoint.revert",715 type: "thread.checkpoint.revert",
704 commandId: asCommandId("cmd-checkpoint-revert-no-session"),716 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"),
705 threadId: THREAD_ID,717 threadId: THREAD_ID,
706 turnCount: 0,718 turnCount: 0,
707 createdAt: nowIso(),719 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 {...@@ -216,7 +216,7 @@ export class GitCoreService {
216216
217 async status(input: GitStatusInput): Promise<GitStatusResult> {217 async status(input: GitStatusInput): Promise<GitStatusResult> {
218 const details = await this.statusDetails(input.cwd);218 const details = await this.statusDetails(input.cwd);
219 return GitStatusResult.makeUnsafe({219 return {
220 branch: details.branch,220 branch: details.branch,
221 hasWorkingTreeChanges: details.hasWorkingTreeChanges,221 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
222 workingTree: details.workingTree,222 workingTree: details.workingTree,
...@@ -224,7 +224,7 @@ export class GitCoreService {...@@ -224,7 +224,7 @@ export class GitCoreService {
224 aheadCount: details.aheadCount,224 aheadCount: details.aheadCount,
225 behindCount: details.behindCount,225 behindCount: details.behindCount,
226 openPr: null,226 openPr: null,
227 });227 };
228 }228 }
229229
230 async statusDetails(cwd: string): Promise<GitStatusDetails> {230 async statusDetails(cwd: string): Promise<GitStatusDetails> {
...@@ -385,11 +385,11 @@ export class GitCoreService {...@@ -385,11 +385,11 @@ export class GitCoreService {
385 });385 });
386 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));386 const afterSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"], true));
387 const refreshed = await this.statusDetails(cwd);387 const refreshed = await this.statusDetails(cwd);
388 return GitPullResult.makeUnsafe({388 return {
389 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",389 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
390 branch,390 branch,
391 upstreamBranch: refreshed.upstreamRef,391 upstreamBranch: refreshed.upstreamRef,
392 });392 };
393 }393 }
394394
395 async readRangeContext(cwd: string, baseBranch: string): Promise<GitRangeContext> {395 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...@@ -40,7 +40,7 @@ const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRu
40 Effect.gen(function* () {40 Effect.gen(function* () {
41 const fileSystem = yield* FileSystem.FileSystem;41 const fileSystem = yield* FileSystem.FileSystem;
42 const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(42 const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(
43 rules.map((rule) => KeybindingRule.makeUnsafe(rule)),43 rules,
44 );44 );
45 yield* fileSystem.writeFileString(configPath, encoded);45 yield* fileSystem.writeFileString(configPath, encoded);
46 });46 });
apps/server/src/keybindings.ts+2-2
...@@ -313,7 +313,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(...@@ -313,7 +313,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe(
313 title: "Invalid keybinding rule",313 title: "Invalid keybinding rule",
314 }),314 }),
315 ),315 ),
316 Effect.map((resolved) => ResolvedKeybindingRule.makeUnsafe(resolved)),316 Effect.map((resolved) => resolved),
317 ),317 ),
318318
319 encode: (resolved) =>319 encode: (resolved) =>
...@@ -456,7 +456,7 @@ const makeKeybindings = Effect.gen(function* () {...@@ -456,7 +456,7 @@ const makeKeybindings = Effect.gen(function* () {
456 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;456 const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`;
457457
458 return Schema.encodeEffect(KeybindingsConfigJson)(458 return Schema.encodeEffect(KeybindingsConfigJson)(
459 rules.map((rule) => KeybindingRule.makeUnsafe(rule)),459 rules,
460 ).pipe(460 ).pipe(
461 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),461 Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })),
462 Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)),462 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";...@@ -22,9 +22,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
22import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";22import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
23import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";23import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
2424
25const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
26const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);25const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
27const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
28const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);26const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
29const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);27const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
30const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);28const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
...@@ -58,7 +56,7 @@ describe("OrchestrationEngine", () => {...@@ -58,7 +56,7 @@ describe("OrchestrationEngine", () => {
58 await system.run(56 await system.run(
59 engine.dispatch({57 engine.dispatch({
60 type: "project.create",58 type: "project.create",
61 commandId: asCommandId("cmd-project-1-create"),59 commandId: CommandId.makeUnsafe("cmd-project-1-create"),
62 projectId: asProjectId("project-1"),60 projectId: asProjectId("project-1"),
63 title: "Project 1",61 title: "Project 1",
64 workspaceRoot: "/tmp/project-1",62 workspaceRoot: "/tmp/project-1",
...@@ -69,8 +67,8 @@ describe("OrchestrationEngine", () => {...@@ -69,8 +67,8 @@ describe("OrchestrationEngine", () => {
69 await system.run(67 await system.run(
70 engine.dispatch({68 engine.dispatch({
71 type: "thread.create",69 type: "thread.create",
72 commandId: asCommandId("cmd-thread-1-create"),70 commandId: CommandId.makeUnsafe("cmd-thread-1-create"),
73 threadId: asThreadId("thread-1"),71 threadId: ThreadId.makeUnsafe("thread-1"),
74 projectId: asProjectId("project-1"),72 projectId: asProjectId("project-1"),
75 title: "Thread",73 title: "Thread",
76 model: "gpt-5-codex",74 model: "gpt-5-codex",
...@@ -82,8 +80,8 @@ describe("OrchestrationEngine", () => {...@@ -82,8 +80,8 @@ describe("OrchestrationEngine", () => {
82 await system.run(80 await system.run(
83 engine.dispatch({81 engine.dispatch({
84 type: "thread.turn.start",82 type: "thread.turn.start",
85 commandId: asCommandId("cmd-turn-start-1"),83 commandId: CommandId.makeUnsafe("cmd-turn-start-1"),
86 threadId: asThreadId("thread-1"),84 threadId: ThreadId.makeUnsafe("thread-1"),
87 message: {85 message: {
88 messageId: asMessageId("msg-1"),86 messageId: asMessageId("msg-1"),
89 role: "user",87 role: "user",
...@@ -108,7 +106,7 @@ describe("OrchestrationEngine", () => {...@@ -108,7 +106,7 @@ describe("OrchestrationEngine", () => {
108 await system.run(106 await system.run(
109 engine.dispatch({107 engine.dispatch({
110 type: "project.create",108 type: "project.create",
111 commandId: asCommandId("cmd-project-replay-create"),109 commandId: CommandId.makeUnsafe("cmd-project-replay-create"),
112 projectId: asProjectId("project-replay"),110 projectId: asProjectId("project-replay"),
113 title: "Replay Project",111 title: "Replay Project",
114 workspaceRoot: "/tmp/project-replay",112 workspaceRoot: "/tmp/project-replay",
...@@ -119,8 +117,8 @@ describe("OrchestrationEngine", () => {...@@ -119,8 +117,8 @@ describe("OrchestrationEngine", () => {
119 await system.run(117 await system.run(
120 engine.dispatch({118 engine.dispatch({
121 type: "thread.create",119 type: "thread.create",
122 commandId: asCommandId("cmd-thread-replay-create"),120 commandId: CommandId.makeUnsafe("cmd-thread-replay-create"),
123 threadId: asThreadId("thread-replay"),121 threadId: ThreadId.makeUnsafe("thread-replay"),
124 projectId: asProjectId("project-replay"),122 projectId: asProjectId("project-replay"),
125 title: "replay",123 title: "replay",
126 model: "gpt-5-codex",124 model: "gpt-5-codex",
...@@ -132,8 +130,8 @@ describe("OrchestrationEngine", () => {...@@ -132,8 +130,8 @@ describe("OrchestrationEngine", () => {
132 await system.run(130 await system.run(
133 engine.dispatch({131 engine.dispatch({
134 type: "thread.delete",132 type: "thread.delete",
135 commandId: asCommandId("cmd-thread-replay-delete"),133 commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"),
136 threadId: asThreadId("thread-replay"),134 threadId: ThreadId.makeUnsafe("thread-replay"),
137 }),135 }),
138 );136 );
139137
...@@ -158,7 +156,7 @@ describe("OrchestrationEngine", () => {...@@ -158,7 +156,7 @@ describe("OrchestrationEngine", () => {
158 await system.run(156 await system.run(
159 engine.dispatch({157 engine.dispatch({
160 type: "project.create",158 type: "project.create",
161 commandId: asCommandId("cmd-project-stream-create"),159 commandId: CommandId.makeUnsafe("cmd-project-stream-create"),
162 projectId: asProjectId("project-stream"),160 projectId: asProjectId("project-stream"),
163 title: "Stream Project",161 title: "Stream Project",
164 workspaceRoot: "/tmp/project-stream",162 workspaceRoot: "/tmp/project-stream",
...@@ -179,8 +177,8 @@ describe("OrchestrationEngine", () => {...@@ -179,8 +177,8 @@ describe("OrchestrationEngine", () => {
179 yield* Effect.sleep("10 millis");177 yield* Effect.sleep("10 millis");
180 yield* engine.dispatch({178 yield* engine.dispatch({
181 type: "thread.create",179 type: "thread.create",
182 commandId: asCommandId("cmd-stream-thread-create"),180 commandId: CommandId.makeUnsafe("cmd-stream-thread-create"),
183 threadId: asThreadId("thread-stream"),181 threadId: ThreadId.makeUnsafe("thread-stream"),
184 projectId: asProjectId("project-stream"),182 projectId: asProjectId("project-stream"),
185 title: "domain-stream",183 title: "domain-stream",
186 model: "gpt-5-codex",184 model: "gpt-5-codex",
...@@ -190,8 +188,8 @@ describe("OrchestrationEngine", () => {...@@ -190,8 +188,8 @@ describe("OrchestrationEngine", () => {
190 });188 });
191 yield* engine.dispatch({189 yield* engine.dispatch({
192 type: "thread.meta.update",190 type: "thread.meta.update",
193 commandId: asCommandId("cmd-stream-thread-update"),191 commandId: CommandId.makeUnsafe("cmd-stream-thread-update"),
194 threadId: asThreadId("thread-stream"),192 threadId: ThreadId.makeUnsafe("thread-stream"),
195 title: "domain-stream-updated",193 title: "domain-stream-updated",
196 });194 });
197 eventTypes.push((yield* Queue.take(eventQueue)).type);195 eventTypes.push((yield* Queue.take(eventQueue)).type);
...@@ -211,7 +209,7 @@ describe("OrchestrationEngine", () => {...@@ -211,7 +209,7 @@ describe("OrchestrationEngine", () => {
211 await system.run(209 await system.run(
212 engine.dispatch({210 engine.dispatch({
213 type: "project.create",211 type: "project.create",
214 commandId: asCommandId("cmd-project-turn-diff-create"),212 commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"),
215 projectId: asProjectId("project-turn-diff"),213 projectId: asProjectId("project-turn-diff"),
216 title: "Turn Diff Project",214 title: "Turn Diff Project",
217 workspaceRoot: "/tmp/project-turn-diff",215 workspaceRoot: "/tmp/project-turn-diff",
...@@ -222,8 +220,8 @@ describe("OrchestrationEngine", () => {...@@ -222,8 +220,8 @@ describe("OrchestrationEngine", () => {
222 await system.run(220 await system.run(
223 engine.dispatch({221 engine.dispatch({
224 type: "thread.create",222 type: "thread.create",
225 commandId: asCommandId("cmd-thread-turn-diff-create"),223 commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"),
226 threadId: asThreadId("thread-turn-diff"),224 threadId: ThreadId.makeUnsafe("thread-turn-diff"),
227 projectId: asProjectId("project-turn-diff"),225 projectId: asProjectId("project-turn-diff"),
228 title: "Turn diff thread",226 title: "Turn diff thread",
229 model: "gpt-5-codex",227 model: "gpt-5-codex",
...@@ -235,8 +233,8 @@ describe("OrchestrationEngine", () => {...@@ -235,8 +233,8 @@ describe("OrchestrationEngine", () => {
235 await system.run(233 await system.run(
236 engine.dispatch({234 engine.dispatch({
237 type: "thread.turn.diff.complete",235 type: "thread.turn.diff.complete",
238 commandId: asCommandId("cmd-turn-diff-complete"),236 commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"),
239 threadId: asThreadId("thread-turn-diff"),237 threadId: ThreadId.makeUnsafe("thread-turn-diff"),
240 turnId: asTurnId("turn-1"),238 turnId: asTurnId("turn-1"),
241 completedAt: createdAt,239 completedAt: createdAt,
242 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),240 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
...@@ -275,7 +273,7 @@ describe("OrchestrationEngine", () => {...@@ -275,7 +273,7 @@ describe("OrchestrationEngine", () => {
275273
276 const flakyStore: OrchestrationEventStoreShape = {274 const flakyStore: OrchestrationEventStoreShape = {
277 append(event) {275 append(event) {
278 if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) {276 if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) {
279 shouldFailFirstAppend = false;277 shouldFailFirstAppend = false;
280 return Effect.fail(278 return Effect.fail(
281 new PersistenceSqlError({279 new PersistenceSqlError({
...@@ -314,7 +312,7 @@ describe("OrchestrationEngine", () => {...@@ -314,7 +312,7 @@ describe("OrchestrationEngine", () => {
314 await runtime.runPromise(312 await runtime.runPromise(
315 engine.dispatch({313 engine.dispatch({
316 type: "project.create",314 type: "project.create",
317 commandId: asCommandId("cmd-project-flaky-create"),315 commandId: CommandId.makeUnsafe("cmd-project-flaky-create"),
318 projectId: asProjectId("project-flaky"),316 projectId: asProjectId("project-flaky"),
319 title: "Flaky Project",317 title: "Flaky Project",
320 workspaceRoot: "/tmp/project-flaky",318 workspaceRoot: "/tmp/project-flaky",
...@@ -327,8 +325,8 @@ describe("OrchestrationEngine", () => {...@@ -327,8 +325,8 @@ describe("OrchestrationEngine", () => {
327 runtime.runPromise(325 runtime.runPromise(
328 engine.dispatch({326 engine.dispatch({
329 type: "thread.create",327 type: "thread.create",
330 commandId: asCommandId("cmd-flaky-1"),328 commandId: CommandId.makeUnsafe("cmd-flaky-1"),
331 threadId: asThreadId("thread-flaky-fail"),329 threadId: ThreadId.makeUnsafe("thread-flaky-fail"),
332 projectId: asProjectId("project-flaky"),330 projectId: asProjectId("project-flaky"),
333 title: "flaky-fail",331 title: "flaky-fail",
334 model: "gpt-5-codex",332 model: "gpt-5-codex",
...@@ -342,8 +340,8 @@ describe("OrchestrationEngine", () => {...@@ -342,8 +340,8 @@ describe("OrchestrationEngine", () => {
342 const result = await runtime.runPromise(340 const result = await runtime.runPromise(
343 engine.dispatch({341 engine.dispatch({
344 type: "thread.create",342 type: "thread.create",
345 commandId: asCommandId("cmd-flaky-2"),343 commandId: CommandId.makeUnsafe("cmd-flaky-2"),
346 threadId: asThreadId("thread-flaky-ok"),344 threadId: ThreadId.makeUnsafe("thread-flaky-ok"),
347 projectId: asProjectId("project-flaky"),345 projectId: asProjectId("project-flaky"),
348 title: "flaky-ok",346 title: "flaky-ok",
349 model: "gpt-5-codex",347 model: "gpt-5-codex",
...@@ -366,8 +364,8 @@ describe("OrchestrationEngine", () => {...@@ -366,8 +364,8 @@ describe("OrchestrationEngine", () => {
366 system.run(364 system.run(
367 engine.dispatch({365 engine.dispatch({
368 type: "thread.turn.start",366 type: "thread.turn.start",
369 commandId: asCommandId("cmd-invariant-missing-thread"),367 commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"),
370 threadId: asThreadId("thread-missing"),368 threadId: ThreadId.makeUnsafe("thread-missing"),
371 message: {369 message: {
372 messageId: asMessageId("msg-missing"),370 messageId: asMessageId("msg-missing"),
373 role: "user",371 role: "user",
...@@ -390,7 +388,7 @@ describe("OrchestrationEngine", () => {...@@ -390,7 +388,7 @@ describe("OrchestrationEngine", () => {
390 await system.run(388 await system.run(
391 engine.dispatch({389 engine.dispatch({
392 type: "project.create",390 type: "project.create",
393 commandId: asCommandId("cmd-project-duplicate-create"),391 commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"),
394 projectId: asProjectId("project-duplicate"),392 projectId: asProjectId("project-duplicate"),
395 title: "Duplicate Project",393 title: "Duplicate Project",
396 workspaceRoot: "/tmp/project-duplicate",394 workspaceRoot: "/tmp/project-duplicate",
...@@ -402,8 +400,8 @@ describe("OrchestrationEngine", () => {...@@ -402,8 +400,8 @@ describe("OrchestrationEngine", () => {
402 await system.run(400 await system.run(
403 engine.dispatch({401 engine.dispatch({
404 type: "thread.create",402 type: "thread.create",
405 commandId: asCommandId("cmd-thread-duplicate-1"),403 commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"),
406 threadId: asThreadId("thread-duplicate"),404 threadId: ThreadId.makeUnsafe("thread-duplicate"),
407 projectId: asProjectId("project-duplicate"),405 projectId: asProjectId("project-duplicate"),
408 title: "duplicate",406 title: "duplicate",
409 model: "gpt-5-codex",407 model: "gpt-5-codex",
...@@ -417,8 +415,8 @@ describe("OrchestrationEngine", () => {...@@ -417,8 +415,8 @@ describe("OrchestrationEngine", () => {
417 system.run(415 system.run(
418 engine.dispatch({416 engine.dispatch({
419 type: "thread.create",417 type: "thread.create",
420 commandId: asCommandId("cmd-thread-duplicate-2"),418 commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"),
421 threadId: asThreadId("thread-duplicate"),419 threadId: ThreadId.makeUnsafe("thread-duplicate"),
422 projectId: asProjectId("project-duplicate"),420 projectId: asProjectId("project-duplicate"),
423 title: "duplicate",421 title: "duplicate",
424 model: "gpt-5-codex",422 model: "gpt-5-codex",
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+66-49
...@@ -5,7 +5,6 @@ import { execFileSync } from "node:child_process";...@@ -5,7 +5,6 @@ import { execFileSync } from "node:child_process";
55
6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";6import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
7import {7import {
8 CheckpointRef,
9 CommandId,8 CommandId,
10 EventId,9 EventId,
11 MessageId,10 MessageId,
...@@ -36,19 +35,13 @@ import {...@@ -36,19 +35,13 @@ import {
36 ProviderService,35 ProviderService,
37 type ProviderServiceShape,36 type ProviderServiceShape,
38} from "../../provider/Services/ProviderService.ts";37} from "../../provider/Services/ProviderService.ts";
38import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts";
3939
40const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
41const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);40const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
42const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
43const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);41const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
44const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);42const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
45const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);43const 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
52function createProviderServiceHarness(cwd: string, hasSession = true) {45function createProviderServiceHarness(cwd: string, hasSession = true) {
53 const now = new Date().toISOString();46 const now = new Date().toISOString();
54 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());47 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
...@@ -109,7 +102,7 @@ async function waitForThread(...@@ -109,7 +102,7 @@ async function waitForThread(
109 activities: ReadonlyArray<{ kind: string }>;102 activities: ReadonlyArray<{ kind: string }>;
110 }> => {103 }> => {
111 const readModel = await Effect.runPromise(engine.getReadModel());104 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"));
113 if (thread && predicate(thread)) {106 if (thread && predicate(thread)) {
114 return thread;107 return thread;
115 }108 }
...@@ -250,7 +243,7 @@ describe("CheckpointReactor", () => {...@@ -250,7 +243,7 @@ describe("CheckpointReactor", () => {
250 await Effect.runPromise(243 await Effect.runPromise(
251 engine.dispatch({244 engine.dispatch({
252 type: "project.create",245 type: "project.create",
253 commandId: asCommandId("cmd-project-create"),246 commandId: CommandId.makeUnsafe("cmd-project-create"),
254 projectId: asProjectId("project-1"),247 projectId: asProjectId("project-1"),
255 title: "Test Project",248 title: "Test Project",
256 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,249 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
...@@ -261,8 +254,8 @@ describe("CheckpointReactor", () => {...@@ -261,8 +254,8 @@ describe("CheckpointReactor", () => {
261 await Effect.runPromise(254 await Effect.runPromise(
262 engine.dispatch({255 engine.dispatch({
263 type: "thread.create",256 type: "thread.create",
264 commandId: asCommandId("cmd-thread-create"),257 commandId: CommandId.makeUnsafe("cmd-thread-create"),
265 threadId: asThreadId("thread-1"),258 threadId: ThreadId.makeUnsafe("thread-1"),
266 projectId: asProjectId("project-1"),259 projectId: asProjectId("project-1"),
267 title: "Thread",260 title: "Thread",
268 model: "gpt-5-codex",261 model: "gpt-5-codex",
...@@ -276,21 +269,21 @@ describe("CheckpointReactor", () => {...@@ -276,21 +269,21 @@ describe("CheckpointReactor", () => {
276 await runtime.runPromise(269 await runtime.runPromise(
277 checkpointStore.captureCheckpoint({270 checkpointStore.captureCheckpoint({
278 cwd,271 cwd,
279 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 0),272 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),
280 }),273 }),
281 );274 );
282 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");275 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
283 await runtime.runPromise(276 await runtime.runPromise(
284 checkpointStore.captureCheckpoint({277 checkpointStore.captureCheckpoint({
285 cwd,278 cwd,
286 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),279 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
287 }),280 }),
288 );281 );
289 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");282 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
290 await runtime.runPromise(283 await runtime.runPromise(
291 checkpointStore.captureCheckpoint({284 checkpointStore.captureCheckpoint({
292 cwd,285 cwd,
293 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2),286 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
294 }),287 }),
295 );288 );
296 }289 }
...@@ -309,10 +302,10 @@ describe("CheckpointReactor", () => {...@@ -309,10 +302,10 @@ describe("CheckpointReactor", () => {
309 await Effect.runPromise(302 await Effect.runPromise(
310 harness.engine.dispatch({303 harness.engine.dispatch({
311 type: "thread.session.set",304 type: "thread.session.set",
312 commandId: asCommandId("cmd-session-set-capture"),305 commandId: CommandId.makeUnsafe("cmd-session-set-capture"),
313 threadId: asThreadId("thread-1"),306 threadId: ThreadId.makeUnsafe("thread-1"),
314 session: {307 session: {
315 threadId: asThreadId("thread-1"),308 threadId: ThreadId.makeUnsafe("thread-1"),
316 status: "ready",309 status: "ready",
317 providerName: "codex",310 providerName: "codex",
318 providerSessionId: asSessionId("sess-1"),311 providerSessionId: asSessionId("sess-1"),
...@@ -334,7 +327,7 @@ describe("CheckpointReactor", () => {...@@ -334,7 +327,7 @@ describe("CheckpointReactor", () => {
334 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),327 threadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
335 turnId: asTurnId("turn-1"),328 turnId: asTurnId("turn-1"),
336 });329 });
337 await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0));330 await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0));
338331
339 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");332 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
340 harness.provider.emit({333 harness.provider.emit({
...@@ -354,13 +347,25 @@ describe("CheckpointReactor", () => {...@@ -354,13 +347,25 @@ describe("CheckpointReactor", () => {
354 (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1,347 (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1,
355 );348 );
356 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);349 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
357 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0))).toBe(true);350 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0))).toBe(
358 expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true);351 true,
352 );
353 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1))).toBe(
354 true,
355 );
359 expect(356 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 ),
361 ).toBe("v1\n");362 ).toBe("v1\n");
362 expect(363 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 ),
364 ).toBe("v2\n");369 ).toBe("v2\n");
365 });370 });
366371
...@@ -374,8 +379,8 @@ describe("CheckpointReactor", () => {...@@ -374,8 +379,8 @@ describe("CheckpointReactor", () => {
374 await Effect.runPromise(379 await Effect.runPromise(
375 harness.engine.dispatch({380 harness.engine.dispatch({
376 type: "thread.turn.start",381 type: "thread.turn.start",
377 commandId: asCommandId("cmd-turn-start-for-baseline"),382 commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"),
378 threadId: asThreadId("thread-1"),383 threadId: ThreadId.makeUnsafe("thread-1"),
379 message: {384 message: {
380 messageId: MessageId.makeUnsafe("message-user-1"),385 messageId: MessageId.makeUnsafe("message-user-1"),
381 role: "user",386 role: "user",
...@@ -386,9 +391,13 @@ describe("CheckpointReactor", () => {...@@ -386,9 +391,13 @@ describe("CheckpointReactor", () => {
386 }),391 }),
387 );392 );
388393
389 await waitForGitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 0));394 await waitForGitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0));
390 expect(395 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 ),
392 ).toBe("v1\n");401 ).toBe("v1\n");
393 });402 });
394403
...@@ -403,10 +412,10 @@ describe("CheckpointReactor", () => {...@@ -403,10 +412,10 @@ describe("CheckpointReactor", () => {
403 await Effect.runPromise(412 await Effect.runPromise(
404 harness.engine.dispatch({413 harness.engine.dispatch({
405 type: "thread.session.set",414 type: "thread.session.set",
406 commandId: asCommandId("cmd-session-set-missing-provider-cwd"),415 commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"),
407 threadId: asThreadId("thread-1"),416 threadId: ThreadId.makeUnsafe("thread-1"),
408 session: {417 session: {
409 threadId: asThreadId("thread-1"),418 threadId: ThreadId.makeUnsafe("thread-1"),
410 status: "running",419 status: "running",
411 providerName: "codex",420 providerName: "codex",
412 providerSessionId: asSessionId("sess-missing"),421 providerSessionId: asSessionId("sess-missing"),
...@@ -432,9 +441,15 @@ describe("CheckpointReactor", () => {...@@ -432,9 +441,15 @@ describe("CheckpointReactor", () => {
432 });441 });
433442
434 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");443 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 );
436 expect(447 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 ),
438 ).toBe("v2\n");453 ).toBe("v2\n");
439 });454 });
440455
...@@ -445,10 +460,10 @@ describe("CheckpointReactor", () => {...@@ -445,10 +460,10 @@ describe("CheckpointReactor", () => {
445 await Effect.runPromise(460 await Effect.runPromise(
446 harness.engine.dispatch({461 harness.engine.dispatch({
447 type: "thread.session.set",462 type: "thread.session.set",
448 commandId: asCommandId("cmd-session-set-checkpoint-captured"),463 commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"),
449 threadId: asThreadId("thread-1"),464 threadId: ThreadId.makeUnsafe("thread-1"),
450 session: {465 session: {
451 threadId: asThreadId("thread-1"),466 threadId: ThreadId.makeUnsafe("thread-1"),
452 status: "ready",467 status: "ready",
453 providerName: "codex",468 providerName: "codex",
454 providerSessionId: asSessionId("sess-1"),469 providerSessionId: asSessionId("sess-1"),
...@@ -488,10 +503,10 @@ describe("CheckpointReactor", () => {...@@ -488,10 +503,10 @@ describe("CheckpointReactor", () => {
488 await Effect.runPromise(503 await Effect.runPromise(
489 harness.engine.dispatch({504 harness.engine.dispatch({
490 type: "thread.session.set",505 type: "thread.session.set",
491 commandId: asCommandId("cmd-session-set"),506 commandId: CommandId.makeUnsafe("cmd-session-set"),
492 threadId: asThreadId("thread-1"),507 threadId: ThreadId.makeUnsafe("thread-1"),
493 session: {508 session: {
494 threadId: asThreadId("thread-1"),509 threadId: ThreadId.makeUnsafe("thread-1"),
495 status: "ready",510 status: "ready",
496 providerName: "codex",511 providerName: "codex",
497 providerSessionId: asSessionId("sess-1"),512 providerSessionId: asSessionId("sess-1"),
...@@ -507,11 +522,11 @@ describe("CheckpointReactor", () => {...@@ -507,11 +522,11 @@ describe("CheckpointReactor", () => {
507 await Effect.runPromise(522 await Effect.runPromise(
508 harness.engine.dispatch({523 harness.engine.dispatch({
509 type: "thread.turn.diff.complete",524 type: "thread.turn.diff.complete",
510 commandId: asCommandId("cmd-diff-1"),525 commandId: CommandId.makeUnsafe("cmd-diff-1"),
511 threadId: asThreadId("thread-1"),526 threadId: ThreadId.makeUnsafe("thread-1"),
512 turnId: asTurnId("turn-1"),527 turnId: asTurnId("turn-1"),
513 completedAt: createdAt,528 completedAt: createdAt,
514 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),529 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),
515 status: "ready",530 status: "ready",
516 files: [],531 files: [],
517 checkpointTurnCount: 1,532 checkpointTurnCount: 1,
...@@ -521,11 +536,11 @@ describe("CheckpointReactor", () => {...@@ -521,11 +536,11 @@ describe("CheckpointReactor", () => {
521 await Effect.runPromise(536 await Effect.runPromise(
522 harness.engine.dispatch({537 harness.engine.dispatch({
523 type: "thread.turn.diff.complete",538 type: "thread.turn.diff.complete",
524 commandId: asCommandId("cmd-diff-2"),539 commandId: CommandId.makeUnsafe("cmd-diff-2"),
525 threadId: asThreadId("thread-1"),540 threadId: ThreadId.makeUnsafe("thread-1"),
526 turnId: asTurnId("turn-2"),541 turnId: asTurnId("turn-2"),
527 completedAt: createdAt,542 completedAt: createdAt,
528 checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 2),543 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),
529 status: "ready",544 status: "ready",
530 files: [],545 files: [],
531 checkpointTurnCount: 2,546 checkpointTurnCount: 2,
...@@ -536,8 +551,8 @@ describe("CheckpointReactor", () => {...@@ -536,8 +551,8 @@ describe("CheckpointReactor", () => {
536 await Effect.runPromise(551 await Effect.runPromise(
537 harness.engine.dispatch({552 harness.engine.dispatch({
538 type: "thread.checkpoint.revert",553 type: "thread.checkpoint.revert",
539 commandId: asCommandId("cmd-revert-request"),554 commandId: CommandId.makeUnsafe("cmd-revert-request"),
540 threadId: asThreadId("thread-1"),555 threadId: ThreadId.makeUnsafe("thread-1"),
541 turnCount: 1,556 turnCount: 1,
542 createdAt,557 createdAt,
543 }),558 }),
...@@ -555,7 +570,9 @@ describe("CheckpointReactor", () => {...@@ -555,7 +570,9 @@ describe("CheckpointReactor", () => {
555 numTurns: 1,570 numTurns: 1,
556 });571 });
557 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");572 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 );
559 });576 });
560577
561 it("appends an error activity when revert is requested without an active session", async () => {578 it("appends an error activity when revert is requested without an active session", async () => {
...@@ -565,8 +582,8 @@ describe("CheckpointReactor", () => {...@@ -565,8 +582,8 @@ describe("CheckpointReactor", () => {
565 await Effect.runPromise(582 await Effect.runPromise(
566 harness.engine.dispatch({583 harness.engine.dispatch({
567 type: "thread.checkpoint.revert",584 type: "thread.checkpoint.revert",
568 commandId: asCommandId("cmd-revert-no-session"),585 commandId: CommandId.makeUnsafe("cmd-revert-no-session"),
569 threadId: asThreadId("thread-1"),586 threadId: ThreadId.makeUnsafe("thread-1"),
570 turnCount: 1,587 turnCount: 1,
571 createdAt,588 createdAt,
572 }),589 }),
apps/server/src/orchestration/Layers/CheckpointReactor.ts+4-10
...@@ -1,17 +1,18 @@...@@ -1,17 +1,18 @@
1import {1import {
2 CheckpointRef,
3 CommandId,2 CommandId,
4 EventId,3 EventId,
5 MessageId,4 MessageId,
6 ProviderSessionId,5 ProviderSessionId,
7 ThreadId,6 ThreadId,
8 TurnId,7 TurnId,
8 type ProjectId,
9 type OrchestrationEvent,9 type OrchestrationEvent,
10 type ProviderRuntimeEvent,10 type ProviderRuntimeEvent,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { Effect, Layer, Option, Queue, Stream } from "effect";12import { Effect, Layer, Option, Queue, Stream } from "effect";
1313
14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";14import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
15import { checkpointRefForThreadTurn } from "../../checkpointing/Refs.ts";
15import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";16import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
16import { ProviderService } from "../../provider/Services/ProviderService.ts";17import { ProviderService } from "../../provider/Services/ProviderService.ts";
17import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";18import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
...@@ -19,8 +20,6 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";...@@ -19,8 +20,6 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
19import { CheckpointStoreError } from "../../checkpointing/Errors.ts";20import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
20import { OrchestrationDispatchError } from "../Errors.ts";21import { OrchestrationDispatchError } from "../Errors.ts";
2122
22const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
23
24type ReactorInput =23type ReactorInput =
25 | {24 | {
26 readonly source: "runtime";25 readonly source: "runtime";
...@@ -31,11 +30,6 @@ type ReactorInput =...@@ -31,11 +30,6 @@ type ReactorInput =
31 readonly event: OrchestrationEvent;30 readonly event: OrchestrationEvent;
32 };31 };
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
39function toTurnId(value: string | undefined): TurnId | null {33function toTurnId(value: string | undefined): TurnId | null {
40 const normalized = value?.trim();34 const normalized = value?.trim();
41 return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null;35 return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null;
...@@ -48,11 +42,11 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined {...@@ -48,11 +42,11 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined {
4842
49function resolveThreadWorkspaceCwd(input: {43function resolveThreadWorkspaceCwd(input: {
50 readonly thread: {44 readonly thread: {
51 readonly projectId: string;45 readonly projectId: ProjectId;
52 readonly worktreePath: string | null;46 readonly worktreePath: string | null;
53 };47 };
54 readonly projects: ReadonlyArray<{48 readonly projects: ReadonlyArray<{
55 readonly id: string;49 readonly id: ProjectId;
56 readonly workspaceRoot: string;50 readonly workspaceRoot: string;
57 }>;51 }>;
58}): string | undefined {52}): string | undefined {
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+1-23
...@@ -5,13 +5,11 @@ import type {...@@ -5,13 +5,11 @@ import type {
5 ThreadId,5 ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { OrchestrationCommand } from "@t3tools/contracts";7import { 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
10import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";10import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
11import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";11import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts";
12import {12import {
13 toOrchestrationCommandDecodeError,
14 toOrchestrationJsonParseError,
15 OrchestrationCommandInvariantError,13 OrchestrationCommandInvariantError,
16 OrchestrationCommandPreviouslyRejectedError,14 OrchestrationCommandPreviouslyRejectedError,
17 type OrchestrationDispatchError,15 type OrchestrationDispatchError,
...@@ -29,8 +27,6 @@ interface CommandEnvelope {...@@ -29,8 +27,6 @@ interface CommandEnvelope {
29 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;27 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;
30}28}
3129
32const decodeUnknownCommand = Schema.decodeUnknownEffect(OrchestrationCommand);
33
34function commandToAggregateRef(command: OrchestrationCommand): {30function commandToAggregateRef(command: OrchestrationCommand): {
35 readonly aggregateKind: "project" | "thread";31 readonly aggregateKind: "project" | "thread";
36 readonly aggregateId: ProjectId | ThreadId;32 readonly aggregateId: ProjectId | ThreadId;
...@@ -175,30 +171,12 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -175,30 +171,12 @@ const makeOrchestrationEngine = Effect.gen(function* () {
175 return yield* Deferred.await(result);171 return yield* Deferred.await(result);
176 });172 });
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
195 const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] =174 const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] =
196 Stream.fromPubSub(eventPubSub);175 Stream.fromPubSub(eventPubSub);
197176
198 return {177 return {
199 getReadModel,178 getReadModel,
200 readEvents,179 readEvents,
201 dispatchUnknownCommand,
202 dispatch,180 dispatch,
203 streamDomainEvents,181 streamDomainEvents,
204 } satisfies OrchestrationEngineShape;182 } satisfies OrchestrationEngineShape;
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2-3
...@@ -18,7 +18,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu...@@ -18,7 +18,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu
18import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";18import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
1919
20const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);20const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
21const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
22const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);21const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
23const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);22const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
24const asEventId = (value: string): EventId => EventId.makeUnsafe(value);23const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
...@@ -225,7 +224,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -225,7 +224,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
225 ]);224 ]);
226 assert.deepEqual(snapshot.threads, [225 assert.deepEqual(snapshot.threads, [
227 {226 {
228 id: asThreadId("thread-1"),227 id: ThreadId.makeUnsafe("thread-1"),
229 projectId: asProjectId("project-1"),228 projectId: asProjectId("project-1"),
230 title: "Thread 1",229 title: "Thread 1",
231 model: "gpt-5-codex",230 model: "gpt-5-codex",
...@@ -269,7 +268,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -269,7 +268,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
269 },268 },
270 ],269 ],
271 session: {270 session: {
272 threadId: asThreadId("thread-1"),271 threadId: ThreadId.makeUnsafe("thread-1"),
273 status: "running",272 status: "running",
274 providerName: "codex",273 providerName: "codex",
275 providerSessionId: asProviderSessionId("provider-session-1"),274 providerSessionId: asProviderSessionId("provider-session-1"),
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+22-24
...@@ -26,9 +26,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";...@@ -26,9 +26,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts";
26import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";26import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
27import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";27import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
2828
29const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
30const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);29const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
31const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
32const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);30const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
33const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);31const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
34const asApprovalRequestId = (value: string): ApprovalRequestId =>32const asApprovalRequestId = (value: string): ApprovalRequestId =>
...@@ -126,7 +124,7 @@ describe("ProviderCommandReactor", () => {...@@ -126,7 +124,7 @@ describe("ProviderCommandReactor", () => {
126 await Effect.runPromise(124 await Effect.runPromise(
127 engine.dispatch({125 engine.dispatch({
128 type: "project.create",126 type: "project.create",
129 commandId: asCommandId("cmd-project-create"),127 commandId: CommandId.makeUnsafe("cmd-project-create"),
130 projectId: asProjectId("project-1"),128 projectId: asProjectId("project-1"),
131 title: "Provider Project",129 title: "Provider Project",
132 workspaceRoot: "/tmp/provider-project",130 workspaceRoot: "/tmp/provider-project",
...@@ -137,8 +135,8 @@ describe("ProviderCommandReactor", () => {...@@ -137,8 +135,8 @@ describe("ProviderCommandReactor", () => {
137 await Effect.runPromise(135 await Effect.runPromise(
138 engine.dispatch({136 engine.dispatch({
139 type: "thread.create",137 type: "thread.create",
140 commandId: asCommandId("cmd-thread-create"),138 commandId: CommandId.makeUnsafe("cmd-thread-create"),
141 threadId: asThreadId("thread-1"),139 threadId: ThreadId.makeUnsafe("thread-1"),
142 projectId: asProjectId("project-1"),140 projectId: asProjectId("project-1"),
143 title: "Thread",141 title: "Thread",
144 model: "gpt-5-codex",142 model: "gpt-5-codex",
...@@ -165,8 +163,8 @@ describe("ProviderCommandReactor", () => {...@@ -165,8 +163,8 @@ describe("ProviderCommandReactor", () => {
165 await Effect.runPromise(163 await Effect.runPromise(
166 harness.engine.dispatch({164 harness.engine.dispatch({
167 type: "thread.turn.start",165 type: "thread.turn.start",
168 commandId: asCommandId("cmd-turn-start-1"),166 commandId: CommandId.makeUnsafe("cmd-turn-start-1"),
169 threadId: asThreadId("thread-1"),167 threadId: ThreadId.makeUnsafe("thread-1"),
170 message: {168 message: {
171 messageId: asMessageId("user-message-1"),169 messageId: asMessageId("user-message-1"),
172 role: "user",170 role: "user",
...@@ -185,7 +183,7 @@ describe("ProviderCommandReactor", () => {...@@ -185,7 +183,7 @@ describe("ProviderCommandReactor", () => {
185 });183 });
186184
187 const readModel = await Effect.runPromise(harness.engine.getReadModel());185 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"));
189 expect(thread?.session?.providerSessionId).toBe("sess-1");187 expect(thread?.session?.providerSessionId).toBe("sess-1");
190 });188 });
191189
...@@ -196,10 +194,10 @@ describe("ProviderCommandReactor", () => {...@@ -196,10 +194,10 @@ describe("ProviderCommandReactor", () => {
196 await Effect.runPromise(194 await Effect.runPromise(
197 harness.engine.dispatch({195 harness.engine.dispatch({
198 type: "thread.session.set",196 type: "thread.session.set",
199 commandId: asCommandId("cmd-session-set"),197 commandId: CommandId.makeUnsafe("cmd-session-set"),
200 threadId: asThreadId("thread-1"),198 threadId: ThreadId.makeUnsafe("thread-1"),
201 session: {199 session: {
202 threadId: asThreadId("thread-1"),200 threadId: ThreadId.makeUnsafe("thread-1"),
203 status: "running",201 status: "running",
204 providerName: "codex",202 providerName: "codex",
205 providerSessionId: asSessionId("sess-1"),203 providerSessionId: asSessionId("sess-1"),
...@@ -215,8 +213,8 @@ describe("ProviderCommandReactor", () => {...@@ -215,8 +213,8 @@ describe("ProviderCommandReactor", () => {
215 await Effect.runPromise(213 await Effect.runPromise(
216 harness.engine.dispatch({214 harness.engine.dispatch({
217 type: "thread.turn.interrupt",215 type: "thread.turn.interrupt",
218 commandId: asCommandId("cmd-turn-interrupt"),216 commandId: CommandId.makeUnsafe("cmd-turn-interrupt"),
219 threadId: asThreadId("thread-1"),217 threadId: ThreadId.makeUnsafe("thread-1"),
220 turnId: asTurnId("turn-1"),218 turnId: asTurnId("turn-1"),
221 createdAt: now,219 createdAt: now,
222 }),220 }),
...@@ -235,10 +233,10 @@ describe("ProviderCommandReactor", () => {...@@ -235,10 +233,10 @@ describe("ProviderCommandReactor", () => {
235 await Effect.runPromise(233 await Effect.runPromise(
236 harness.engine.dispatch({234 harness.engine.dispatch({
237 type: "thread.session.set",235 type: "thread.session.set",
238 commandId: asCommandId("cmd-session-set-for-approval"),236 commandId: CommandId.makeUnsafe("cmd-session-set-for-approval"),
239 threadId: asThreadId("thread-1"),237 threadId: ThreadId.makeUnsafe("thread-1"),
240 session: {238 session: {
241 threadId: asThreadId("thread-1"),239 threadId: ThreadId.makeUnsafe("thread-1"),
242 status: "running",240 status: "running",
243 providerName: "codex",241 providerName: "codex",
244 providerSessionId: asSessionId("sess-1"),242 providerSessionId: asSessionId("sess-1"),
...@@ -254,8 +252,8 @@ describe("ProviderCommandReactor", () => {...@@ -254,8 +252,8 @@ describe("ProviderCommandReactor", () => {
254 await Effect.runPromise(252 await Effect.runPromise(
255 harness.engine.dispatch({253 harness.engine.dispatch({
256 type: "thread.approval.respond",254 type: "thread.approval.respond",
257 commandId: asCommandId("cmd-approval-respond"),255 commandId: CommandId.makeUnsafe("cmd-approval-respond"),
258 threadId: asThreadId("thread-1"),256 threadId: ThreadId.makeUnsafe("thread-1"),
259 requestId: asApprovalRequestId("approval-request-1"),257 requestId: asApprovalRequestId("approval-request-1"),
260 decision: "accept",258 decision: "accept",
261 createdAt: now,259 createdAt: now,
...@@ -277,10 +275,10 @@ describe("ProviderCommandReactor", () => {...@@ -277,10 +275,10 @@ describe("ProviderCommandReactor", () => {
277 await Effect.runPromise(275 await Effect.runPromise(
278 harness.engine.dispatch({276 harness.engine.dispatch({
279 type: "thread.session.set",277 type: "thread.session.set",
280 commandId: asCommandId("cmd-session-set-for-stop"),278 commandId: CommandId.makeUnsafe("cmd-session-set-for-stop"),
281 threadId: asThreadId("thread-1"),279 threadId: ThreadId.makeUnsafe("thread-1"),
282 session: {280 session: {
283 threadId: asThreadId("thread-1"),281 threadId: ThreadId.makeUnsafe("thread-1"),
284 status: "ready",282 status: "ready",
285 providerName: "codex",283 providerName: "codex",
286 providerSessionId: asSessionId("sess-1"),284 providerSessionId: asSessionId("sess-1"),
...@@ -296,15 +294,15 @@ describe("ProviderCommandReactor", () => {...@@ -296,15 +294,15 @@ describe("ProviderCommandReactor", () => {
296 await Effect.runPromise(294 await Effect.runPromise(
297 harness.engine.dispatch({295 harness.engine.dispatch({
298 type: "thread.session.stop",296 type: "thread.session.stop",
299 commandId: asCommandId("cmd-session-stop"),297 commandId: CommandId.makeUnsafe("cmd-session-stop"),
300 threadId: asThreadId("thread-1"),298 threadId: ThreadId.makeUnsafe("thread-1"),
301 createdAt: now,299 createdAt: now,
302 }),300 }),
303 );301 );
304302
305 await waitFor(() => harness.stopSession.mock.calls.length === 1);303 await waitFor(() => harness.stopSession.mock.calls.length === 1);
306 const readModel = await Effect.runPromise(harness.engine.getReadModel());304 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"));
308 expect(thread?.session).not.toBeNull();306 expect(thread?.session).not.toBeNull();
309 expect(thread?.session?.status).toBe("stopped");307 expect(thread?.session?.status).toBe("stopped");
310 expect(thread?.session?.providerSessionId).toBeNull();308 expect(thread?.session?.providerSessionId).toBeNull();
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+7-9
...@@ -28,9 +28,7 @@ import {...@@ -28,9 +28,7 @@ import {
28} from "../Services/OrchestrationEngine.ts";28} from "../Services/OrchestrationEngine.ts";
29import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";29import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
3030
31const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
32const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);31const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
33const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
34const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);32const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
35const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);33const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
36const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);34const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
...@@ -78,7 +76,7 @@ async function waitForThread(...@@ -78,7 +76,7 @@ async function waitForThread(
78 activities: ReadonlyArray<{ kind: string }>;76 activities: ReadonlyArray<{ kind: string }>;
79 }> => {77 }> => {
80 const readModel = await Effect.runPromise(engine.getReadModel());78 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"));
82 if (thread && predicate(thread)) {80 if (thread && predicate(thread)) {
83 return thread;81 return thread;
84 }82 }
...@@ -132,7 +130,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -132,7 +130,7 @@ describe("ProviderRuntimeIngestion", () => {
132 await Effect.runPromise(130 await Effect.runPromise(
133 engine.dispatch({131 engine.dispatch({
134 type: "project.create",132 type: "project.create",
135 commandId: asCommandId("cmd-provider-project-create"),133 commandId: CommandId.makeUnsafe("cmd-provider-project-create"),
136 projectId: asProjectId("project-1"),134 projectId: asProjectId("project-1"),
137 title: "Provider Project",135 title: "Provider Project",
138 workspaceRoot: "/tmp/provider-project",136 workspaceRoot: "/tmp/provider-project",
...@@ -143,8 +141,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -143,8 +141,8 @@ describe("ProviderRuntimeIngestion", () => {
143 await Effect.runPromise(141 await Effect.runPromise(
144 engine.dispatch({142 engine.dispatch({
145 type: "thread.create",143 type: "thread.create",
146 commandId: asCommandId("cmd-thread-create"),144 commandId: CommandId.makeUnsafe("cmd-thread-create"),
147 threadId: asThreadId("thread-1"),145 threadId: ThreadId.makeUnsafe("thread-1"),
148 projectId: asProjectId("project-1"),146 projectId: asProjectId("project-1"),
149 title: "Thread",147 title: "Thread",
150 model: "gpt-5-codex",148 model: "gpt-5-codex",
...@@ -156,10 +154,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -156,10 +154,10 @@ describe("ProviderRuntimeIngestion", () => {
156 await Effect.runPromise(154 await Effect.runPromise(
157 engine.dispatch({155 engine.dispatch({
158 type: "thread.session.set",156 type: "thread.session.set",
159 commandId: asCommandId("cmd-session-seed"),157 commandId: CommandId.makeUnsafe("cmd-session-seed"),
160 threadId: asThreadId("thread-1"),158 threadId: ThreadId.makeUnsafe("thread-1"),
161 session: {159 session: {
162 threadId: asThreadId("thread-1"),160 threadId: ThreadId.makeUnsafe("thread-1"),
163 status: "ready",161 status: "ready",
164 providerName: "codex",162 providerName: "codex",
165 providerSessionId: asSessionId("sess-1"),163 providerSessionId: asSessionId("sess-1"),
apps/server/src/orchestration/Services/OrchestrationEngine.ts+1-13
...@@ -18,7 +18,7 @@ import type {...@@ -18,7 +18,7 @@ import type {
18import { ServiceMap } from "effect";18import { ServiceMap } from "effect";
19import type { Effect, Stream } from "effect";19import type { Effect, Stream } from "effect";
2020
21import type { OrchestrationDispatchError, OrchestrationEngineError } from "../Errors.ts";21import type { OrchestrationDispatchError } from "../Errors.ts";
22import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";22import type { OrchestrationEventStoreError } from "../../persistence/Errors.ts";
2323
24export interface OrchestrationEngineShape {24export interface OrchestrationEngineShape {
...@@ -39,18 +39,6 @@ export interface OrchestrationEngineShape {...@@ -39,18 +39,6 @@ export interface OrchestrationEngineShape {
39 fromSequenceExclusive: number,39 fromSequenceExclusive: number,
40 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;40 ) => 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
54 /**42 /**
55 * Dispatch a validated orchestration command.43 * Dispatch a validated orchestration command.
56 *44 *
apps/server/src/orchestration/decider.projectScripts.test.ts+12-14
...@@ -5,10 +5,8 @@ import { Effect } from "effect";...@@ -5,10 +5,8 @@ import { Effect } from "effect";
5import { decideOrchestrationCommand } from "./decider.ts";5import { decideOrchestrationCommand } from "./decider.ts";
6import { createEmptyReadModel, projectEvent } from "./projector.ts";6import { createEmptyReadModel, projectEvent } from "./projector.ts";
77
8const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
9const asEventId = (value: string): EventId => EventId.makeUnsafe(value);8const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
10const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);9const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
11const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
12const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);10const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
1311
14describe("decider project scripts", () => {12describe("decider project scripts", () => {
...@@ -20,7 +18,7 @@ describe("decider project scripts", () => {...@@ -20,7 +18,7 @@ describe("decider project scripts", () => {
20 decideOrchestrationCommand({18 decideOrchestrationCommand({
21 command: {19 command: {
22 type: "project.create",20 type: "project.create",
23 commandId: asCommandId("cmd-project-create-scripts"),21 commandId: CommandId.makeUnsafe("cmd-project-create-scripts"),
24 projectId: asProjectId("project-scripts"),22 projectId: asProjectId("project-scripts"),
25 title: "Scripts",23 title: "Scripts",
26 workspaceRoot: "/tmp/scripts",24 workspaceRoot: "/tmp/scripts",
...@@ -46,9 +44,9 @@ describe("decider project scripts", () => {...@@ -46,9 +44,9 @@ describe("decider project scripts", () => {
46 aggregateId: asProjectId("project-scripts"),44 aggregateId: asProjectId("project-scripts"),
47 type: "project.created",45 type: "project.created",
48 occurredAt: now,46 occurredAt: now,
49 commandId: asCommandId("cmd-project-create-scripts"),47 commandId: CommandId.makeUnsafe("cmd-project-create-scripts"),
50 causationEventId: null,48 causationEventId: null,
51 correlationId: asCommandId("cmd-project-create-scripts"),49 correlationId: CommandId.makeUnsafe("cmd-project-create-scripts"),
52 metadata: {},50 metadata: {},
53 payload: {51 payload: {
54 projectId: asProjectId("project-scripts"),52 projectId: asProjectId("project-scripts"),
...@@ -76,7 +74,7 @@ describe("decider project scripts", () => {...@@ -76,7 +74,7 @@ describe("decider project scripts", () => {
76 decideOrchestrationCommand({74 decideOrchestrationCommand({
77 command: {75 command: {
78 type: "project.meta.update",76 type: "project.meta.update",
79 commandId: asCommandId("cmd-project-update-scripts"),77 commandId: CommandId.makeUnsafe("cmd-project-update-scripts"),
80 projectId: asProjectId("project-scripts"),78 projectId: asProjectId("project-scripts"),
81 scripts: Array.from(scripts),79 scripts: Array.from(scripts),
82 },80 },
...@@ -100,9 +98,9 @@ describe("decider project scripts", () => {...@@ -100,9 +98,9 @@ describe("decider project scripts", () => {
100 aggregateId: asProjectId("project-1"),98 aggregateId: asProjectId("project-1"),
101 type: "project.created",99 type: "project.created",
102 occurredAt: now,100 occurredAt: now,
103 commandId: asCommandId("cmd-project-create"),101 commandId: CommandId.makeUnsafe("cmd-project-create"),
104 causationEventId: null,102 causationEventId: null,
105 correlationId: asCommandId("cmd-project-create"),103 correlationId: CommandId.makeUnsafe("cmd-project-create"),
106 metadata: {},104 metadata: {},
107 payload: {105 payload: {
108 projectId: asProjectId("project-1"),106 projectId: asProjectId("project-1"),
...@@ -120,15 +118,15 @@ describe("decider project scripts", () => {...@@ -120,15 +118,15 @@ describe("decider project scripts", () => {
120 sequence: 2,118 sequence: 2,
121 eventId: asEventId("evt-thread-create"),119 eventId: asEventId("evt-thread-create"),
122 aggregateKind: "thread",120 aggregateKind: "thread",
123 aggregateId: asThreadId("thread-1"),121 aggregateId: ThreadId.makeUnsafe("thread-1"),
124 type: "thread.created",122 type: "thread.created",
125 occurredAt: now,123 occurredAt: now,
126 commandId: asCommandId("cmd-thread-create"),124 commandId: CommandId.makeUnsafe("cmd-thread-create"),
127 causationEventId: null,125 causationEventId: null,
128 correlationId: asCommandId("cmd-thread-create"),126 correlationId: CommandId.makeUnsafe("cmd-thread-create"),
129 metadata: {},127 metadata: {},
130 payload: {128 payload: {
131 threadId: asThreadId("thread-1"),129 threadId: ThreadId.makeUnsafe("thread-1"),
132 projectId: asProjectId("project-1"),130 projectId: asProjectId("project-1"),
133 title: "Thread",131 title: "Thread",
134 model: "gpt-5-codex",132 model: "gpt-5-codex",
...@@ -144,8 +142,8 @@ describe("decider project scripts", () => {...@@ -144,8 +142,8 @@ describe("decider project scripts", () => {
144 decideOrchestrationCommand({142 decideOrchestrationCommand({
145 command: {143 command: {
146 type: "thread.turn.start",144 type: "thread.turn.start",
147 commandId: asCommandId("cmd-turn-start"),145 commandId: CommandId.makeUnsafe("cmd-turn-start"),
148 threadId: asThreadId("thread-1"),146 threadId: ThreadId.makeUnsafe("thread-1"),
149 message: {147 message: {
150 messageId: asMessageId("message-user-1"),148 messageId: asMessageId("message-user-1"),
151 role: "user",149 role: "user",
apps/server/src/provider/Layers/CodexAdapter.test.ts+5-6
...@@ -23,7 +23,6 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts";...@@ -23,7 +23,6 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts";
23import { makeCodexAdapterLive } from "./CodexAdapter.ts";23import { makeCodexAdapterLive } from "./CodexAdapter.ts";
2424
25const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);25const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
26const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
27const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);26const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
28const asEventId = (value: string): EventId => EventId.makeUnsafe(value);27const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
29const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);28const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value);
...@@ -36,7 +35,7 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -36,7 +35,7 @@ class FakeCodexManager extends CodexAppServerManager {
36 sessionId: asSessionId("sess-1"),35 sessionId: asSessionId("sess-1"),
37 provider: "codex",36 provider: "codex",
38 status: "ready",37 status: "ready",
39 threadId: asThreadId("thread-1"),38 threadId: ProviderThreadId.makeUnsafe("thread-1"),
40 cwd: input.cwd,39 cwd: input.cwd,
41 createdAt: now,40 createdAt: now,
42 updatedAt: now,41 updatedAt: now,
...@@ -46,7 +45,7 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -46,7 +45,7 @@ class FakeCodexManager extends CodexAppServerManager {
4645
47 public sendTurnImpl = vi.fn(46 public sendTurnImpl = vi.fn(
48 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({47 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({
49 threadId: asThreadId("thread-1"),48 threadId: ProviderThreadId.makeUnsafe("thread-1"),
50 turnId: asTurnId("turn-1"),49 turnId: asTurnId("turn-1"),
51 }),50 }),
52 );51 );
...@@ -56,12 +55,12 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -56,12 +55,12 @@ class FakeCodexManager extends CodexAppServerManager {
56 );55 );
5756
58 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({57 public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({
59 threadId: asThreadId("thread-1"),58 threadId: ProviderThreadId.makeUnsafe("thread-1"),
60 turns: [],59 turns: [],
61 }));60 }));
6261
63 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({62 public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({
64 threadId: asThreadId("thread-1"),63 threadId: ProviderThreadId.makeUnsafe("thread-1"),
65 turns: [],64 turns: [],
66 }));65 }));
6766
...@@ -194,7 +193,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -194,7 +193,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
194 sessionId: asSessionId("sess-1"),193 sessionId: asSessionId("sess-1"),
195 createdAt: new Date().toISOString(),194 createdAt: new Date().toISOString(),
196 method: "item/completed",195 method: "item/completed",
197 threadId: asThreadId("thread-1"),196 threadId: ProviderThreadId.makeUnsafe("thread-1"),
198 turnId: asTurnId("turn-1"),197 turnId: asTurnId("turn-1"),
199 itemId: asItemId("msg_1"),198 itemId: asItemId("msg_1"),
200 payload: {199 payload: {
apps/server/src/provider/Layers/ProviderService.test.ts+5-6
...@@ -21,7 +21,7 @@ import {...@@ -21,7 +21,7 @@ import {
21import { it, assert, vi } from "@effect/vitest";21import { it, assert, vi } from "@effect/vitest";
22import { assertFailure } from "@effect/vitest/utils";22import { 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";
25import * as SqlClient from "effect/unstable/sql/SqlClient";25import * as SqlClient from "effect/unstable/sql/SqlClient";
2626
27import {27import {
...@@ -46,7 +46,6 @@ import {...@@ -46,7 +46,6 @@ import {
46} from "../../persistence/Layers/Sqlite.ts";46} from "../../persistence/Layers/Sqlite.ts";
4747
48const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);48const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
49const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
50const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);49const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
51const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);50const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
52const asEventId = (value: string): EventId => EventId.makeUnsafe(value);51const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
...@@ -568,7 +567,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -568,7 +567,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
568 provider: "codex",567 provider: "codex",
569 sessionId: session.sessionId,568 sessionId: session.sessionId,
570 createdAt: new Date().toISOString(),569 createdAt: new Date().toISOString(),
571 threadId: asThreadId("thread-1"),570 threadId: ThreadId.makeUnsafe("thread-1"),
572 turnId: asTurnId("turn-1"),571 turnId: asTurnId("turn-1"),
573 status: "completed",572 status: "completed",
574 };573 };
...@@ -616,7 +615,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -616,7 +615,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
616 provider: "codex",615 provider: "codex",
617 sessionId: session.sessionId,616 sessionId: session.sessionId,
618 createdAt: new Date().toISOString(),617 createdAt: new Date().toISOString(),
619 threadId: asThreadId("thread-1"),618 threadId: ThreadId.makeUnsafe("thread-1"),
620 turnId: asTurnId("turn-1"),619 turnId: asTurnId("turn-1"),
621 toolKind: "command",620 toolKind: "command",
622 title: "Command run",621 title: "Command run",
...@@ -628,7 +627,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -628,7 +627,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
628 provider: "codex",627 provider: "codex",
629 sessionId: session.sessionId,628 sessionId: session.sessionId,
630 createdAt: new Date().toISOString(),629 createdAt: new Date().toISOString(),
631 threadId: asThreadId("thread-1"),630 threadId: ThreadId.makeUnsafe("thread-1"),
632 turnId: asTurnId("turn-1"),631 turnId: asTurnId("turn-1"),
633 delta: "hello",632 delta: "hello",
634 },633 },
...@@ -638,7 +637,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -638,7 +637,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
638 provider: "codex",637 provider: "codex",
639 sessionId: session.sessionId,638 sessionId: session.sessionId,
640 createdAt: new Date().toISOString(),639 createdAt: new Date().toISOString(),
641 threadId: asThreadId("thread-1"),640 threadId: ThreadId.makeUnsafe("thread-1"),
642 turnId: asTurnId("turn-1"),641 turnId: asTurnId("turn-1"),
643 status: "completed",642 status: "completed",
644 },643 },
apps/server/src/serverLayers.ts+8
...@@ -4,8 +4,10 @@ import { NodeServices } from "@effect/platform-node";...@@ -4,8 +4,10 @@ import { NodeServices } from "@effect/platform-node";
4import { Effect, Layer } from "effect";4import { Effect, Layer } from "effect";
5import * as SqlClient from "effect/unstable/sql/SqlClient";5import * as SqlClient from "effect/unstable/sql/SqlClient";
66
7import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
7import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";8import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
8import { ServerConfig } from "./config";9import { ServerConfig } from "./config";
10import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
9import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";11import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";
10import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";12import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
11import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";13import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
...@@ -55,6 +57,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<...@@ -55,6 +57,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<
55 | OrchestrationEngineService57 | OrchestrationEngineService
56 | ProjectionSnapshotQuery58 | ProjectionSnapshotQuery
57 | CheckpointStore59 | CheckpointStore
60 | CheckpointDiffQuery
58 | OrchestrationReactor61 | OrchestrationReactor
59 | TerminalManager62 | TerminalManager
60 | Keybindings,63 | Keybindings,
...@@ -67,11 +70,16 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<...@@ -67,11 +70,16 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer<
67 Layer.provide(OrchestrationCommandReceiptRepositoryLive),70 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
68 );71 );
69 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));72 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
73 const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe(
74 Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
75 Layer.provideMerge(checkpointStoreLayer),
76 );
7077
71 const runtimeServicesLayer = Layer.mergeAll(78 const runtimeServicesLayer = Layer.mergeAll(
72 orchestrationLayer,79 orchestrationLayer,
73 OrchestrationProjectionSnapshotQueryLive,80 OrchestrationProjectionSnapshotQueryLive,
74 checkpointStoreLayer,81 checkpointStoreLayer,
82 checkpointDiffQueryLayer,
75 );83 );
76 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(84 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
77 Layer.provideMerge(runtimeServicesLayer),85 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/terminalManager.test.ts+4-3
...@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest";...@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest";
1111
12import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";12import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
13import { TerminalManagerRuntime } from "./terminalManager";13import { TerminalManagerRuntime } from "./terminalManager";
14import { Encoding } from "effect";
1415
15class FakePtyProcess implements PtyProcess {16class FakePtyProcess implements PtyProcess {
16 readonly writes: string[] = [];17 readonly writes: string[] = [];
...@@ -107,15 +108,15 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu...@@ -107,15 +108,15 @@ function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInpu
107}108}
108109
109function historyLogName(threadId: string): string {110function historyLogName(threadId: string): string {
110 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`;111 return `terminal_${Encoding.encodeBase64Url(threadId)}.log`;
111}112}
112113
113function multiTerminalHistoryLogName(threadId: string, terminalId: string): string {114function multiTerminalHistoryLogName(threadId: string, terminalId: string): string {
114 const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;115 const threadPart = `terminal_${Encoding.encodeBase64Url(threadId)}`;
115 if (terminalId === DEFAULT_TERMINAL_ID) {116 if (terminalId === DEFAULT_TERMINAL_ID) {
116 return `${threadPart}.log`;117 return `${threadPart}.log`;
117 }118 }
118 return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`;119 return `${threadPart}_${Encoding.encodeBase64Url(terminalId)}.log`;
119}120}
120121
121function historyLogPath(logsDir: string, threadId = "thread-1"): string {122function historyLogPath(logsDir: string, threadId = "thread-1"): string {
apps/server/src/terminalManager.ts+3-3
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 type TerminalSessionSnapshot,13 type TerminalSessionSnapshot,
14 type TerminalSessionStatus,14 type TerminalSessionStatus,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { Effect, Layer, Schema, ServiceMap } from "effect";16import { Effect, Encoding, Layer, Schema, ServiceMap } from "effect";
1717
18import { createLogger } from "./logger";18import { createLogger } from "./logger";
19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter";
...@@ -269,11 +269,11 @@ function legacySafeThreadId(threadId: string): string {...@@ -269,11 +269,11 @@ function legacySafeThreadId(threadId: string): string {
269}269}
270270
271function toSafeThreadId(threadId: string): string {271function toSafeThreadId(threadId: string): string {
272 return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`;272 return `terminal_${Encoding.encodeBase64Url(threadId)}`;
273}273}
274274
275function toSafeTerminalId(terminalId: string): string {275function toSafeTerminalId(terminalId: string): string {
276 return Buffer.from(terminalId, "utf8").toString("base64url");276 return Encoding.encodeBase64Url(terminalId);
277}277}
278278
279function toSessionKey(threadId: string, terminalId: string): string {279function toSessionKey(threadId: string, terminalId: string): string {
apps/server/src/wsServer.test.ts+21-1
...@@ -175,7 +175,7 @@ class MockTerminalManager implements TerminalManagerShape {...@@ -175,7 +175,7 @@ class MockTerminalManager implements TerminalManagerShape {
175 this.sessions.delete(this.key(input.threadId, input.terminalId));175 this.sessions.delete(this.key(input.threadId, input.terminalId));
176 return;176 return;
177 }177 }
178 for (const key of [...this.sessions.keys()]) {178 for (const key of this.sessions.keys()) {
179 if (key.startsWith(`${input.threadId}\u0000`)) {179 if (key.startsWith(`${input.threadId}\u0000`)) {
180 this.sessions.delete(key);180 this.sessions.delete(key);
181 }181 }
...@@ -837,6 +837,26 @@ describe("WebSocket Server", () => {...@@ -837,6 +837,26 @@ describe("WebSocket Server", () => {
837 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");837 expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
838 });838 });
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
840 it("returns error when requesting full thread diff for unknown thread", async () => {860 it("returns error when requesting full thread diff for unknown thread", async () => {
841 server = await createTestServer({ cwd: "/test" });861 server = await createTestServer({ cwd: "/test" });
842 const addr = server.address();862 const addr = server.address();
apps/server/src/wsServer.ts+29-153
...@@ -4,19 +4,19 @@ import path from "node:path";...@@ -4,19 +4,19 @@ import path from "node:path";
4import type { Duplex } from "node:stream";4import type { Duplex } from "node:stream";
55
6import {6import {
7 CheckpointRef,7 CommandId,
8 ORCHESTRATION_WS_CHANNELS,8 ORCHESTRATION_WS_CHANNELS,
9 ORCHESTRATION_WS_METHODS,9 ORCHESTRATION_WS_METHODS,
10 ProjectId,
10 TerminalEvent,11 TerminalEvent,
11 WS_CHANNELS,12 WS_CHANNELS,
12 WS_METHODS,13 WS_METHODS,
13 WebSocketRequest,14 WebSocketRequest,
14 WebSocketResponse,
15 WsPush,15 WsPush,
16 WsResponse,16 WsResponse,
17} from "@t3tools/contracts";17} from "@t3tools/contracts";
18import { NodeHttpServer } from "@effect/platform-node";18import { 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";
20import { WebSocketServer, type WebSocket } from "ws";20import { WebSocketServer, type WebSocket } from "ws";
2121
22import { createLogger } from "./logger";22import { createLogger } from "./logger";
...@@ -37,7 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati...@@ -37,7 +37,7 @@ import { OrchestrationEngineService } from "./orchestration/Services/Orchestrati
37import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";37import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery";
38import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";38import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
39import { ProviderService } from "./provider/Services/ProviderService";39import { ProviderService } from "./provider/Services/ProviderService";
40import { CheckpointStore } from "./checkpointing/Services/CheckpointStore";40import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery";
41import { clamp } from "effect/Number";41import { clamp } from "effect/Number";
42import { Open } from "./open";42import { Open } from "./open";
43import { ServerConfig } from "./config";43import { ServerConfig } from "./config";
...@@ -133,22 +133,14 @@ function websocketRawToString(raw: unknown): string | null {...@@ -133,22 +133,14 @@ function websocketRawToString(raw: unknown): string | null {
133 return null;133 return null;
134}134}
135135
136function stripRequestTag<T extends { _tag: string }>(body: T): Omit<T, "_tag"> {136function stripRequestTag<T extends { _tag: string }>(body: T) {
137 const { _tag: _ignoredTag, ...rest } = body;137 return Struct.omit(body, ["_tag"]);
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}`);
146}138}
147139
148export type ServerCoreRuntimeServices =140export type ServerCoreRuntimeServices =
149 | OrchestrationEngineService141 | OrchestrationEngineService
150 | ProjectionSnapshotQuery142 | ProjectionSnapshotQuery
151 | CheckpointStore143 | CheckpointDiffQuery
152 | OrchestrationReactor144 | OrchestrationReactor
153 | ProviderService;145 | ProviderService;
154146
...@@ -301,7 +293,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -301,7 +293,7 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
301293
302 const orchestrationEngine = yield* OrchestrationEngineService;294 const orchestrationEngine = yield* OrchestrationEngineService;
303 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;295 const projectionReadModelQuery = yield* ProjectionSnapshotQuery;
304 const liveCheckpointStore = yield* CheckpointStore;296 const checkpointDiffQuery = yield* CheckpointDiffQuery;
305 const liveProviderService = yield* ProviderService;297 const liveProviderService = yield* ProviderService;
306 const orchestrationReactor = yield* OrchestrationReactor;298 const orchestrationReactor = yield* OrchestrationReactor;
307 const { openInEditor } = yield* Open;299 const { openInEditor } = yield* Open;
...@@ -338,10 +330,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -338,10 +330,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
338 if (!existing) {330 if (!existing) {
339 const createdAt = new Date().toISOString();331 const createdAt = new Date().toISOString();
340 const projectName = path.basename(cwd) || "project";332 const projectName = path.basename(cwd) || "project";
341 yield* orchestrationEngine.dispatchUnknownCommand({333 yield* orchestrationEngine.dispatch({
342 type: "project.create",334 type: "project.create",
343 commandId: crypto.randomUUID(),335 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
344 projectId: crypto.randomUUID(),336 projectId: ProjectId.makeUnsafe(crypto.randomUUID()),
345 title: projectName,337 title: projectName,
346 workspaceRoot: cwd,338 workspaceRoot: cwd,
347 defaultModel: "gpt-5-codex",339 defaultModel: "gpt-5-codex",
...@@ -351,110 +343,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -351,110 +343,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
351 }343 }
352344
353 const routeRequest = Effect.fn(function* (request: WebSocketRequest) {345 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
458 switch (request.body._tag) {346 switch (request.body._tag) {
459 case ORCHESTRATION_WS_METHODS.getSnapshot:347 case ORCHESTRATION_WS_METHODS.getSnapshot:
460 return yield* projectionReadModelQuery.getSnapshot();348 return yield* projectionReadModelQuery.getSnapshot();
...@@ -466,16 +354,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -466,16 +354,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
466354
467 case ORCHESTRATION_WS_METHODS.getTurnDiff: {355 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
468 const body = stripRequestTag(request.body);356 const body = stripRequestTag(request.body);
469 return yield* computeTurnDiff(body);357 return yield* checkpointDiffQuery.getTurnDiff(body);
470 }358 }
471359
472 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {360 case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
473 const body = stripRequestTag(request.body);361 const body = stripRequestTag(request.body);
474 return yield* computeTurnDiff({362 return yield* checkpointDiffQuery.getFullThreadDiff(body);
475 threadId: body.threadId,
476 fromTurnCount: 0,
477 toTurnCount: body.toTurnCount,
478 });
479 }363 }
480364
481 case ORCHESTRATION_WS_METHODS.replayEvents: {365 case ORCHESTRATION_WS_METHODS.replayEvents: {
...@@ -599,46 +483,38 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E...@@ -599,46 +483,38 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E
599483
600 const messageText = websocketRawToString(raw);484 const messageText = websocketRawToString(raw);
601 if (messageText === null) {485 if (messageText === null) {
602 const errorResponse = yield* encodeResponse(486 const errorResponse = yield* encodeResponse({
603 new WebSocketResponse({487 id: "unknown",
604 id: "unknown",488 error: { message: "Invalid request format: Failed to read message" },
605 error: { message: "Invalid request format: Failed to read message" },489 });
606 }),
607 );
608 ws.send(errorResponse);490 ws.send(errorResponse);
609 return;491 return;
610 }492 }
611493
612 const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText);494 const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText);
613 if (request._tag === "Failure") {495 if (request._tag === "Failure") {
614 const errorResponse = yield* encodeResponse(496 const errorResponse = yield* encodeResponse({
615 new WebSocketResponse({497 id: "unknown",
616 id: "unknown",498 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
617 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },499 });
618 }),
619 );
620 ws.send(errorResponse);500 ws.send(errorResponse);
621 return;501 return;
622 }502 }
623503
624 const result = yield* Effect.exit(routeRequest(request.value));504 const result = yield* Effect.exit(routeRequest(request.value));
625 if (result._tag === "Failure") {505 if (result._tag === "Failure") {
626 const errorResponse = yield* encodeResponse(506 const errorResponse = yield* encodeResponse({
627 new WebSocketResponse({507 id: request.value.id,
628 id: request.value.id,508 error: { message: Cause.pretty(result.cause) },
629 error: { message: Cause.pretty(result.cause) },509 });
630 }),
631 );
632 ws.send(errorResponse);510 ws.send(errorResponse);
633 return;511 return;
634 }512 }
635513
636 const response = yield* encodeResponse(514 const response = yield* encodeResponse({
637 new WebSocketResponse({515 id: request.value.id,
638 id: request.value.id,516 result: result.value,
639 result: result.value,517 });
640 }),
641 );
642 ws.send(response);518 ws.send(response);
643 });519 });
644520
apps/web/src/components/BranchToolbar.logic.test.ts+7-4
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { GitBranch } from "@t3tools/contracts";1import { ThreadId, type GitBranch } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
3import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";3import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
44
...@@ -19,8 +19,9 @@ const branches: GitBranch[] = [...@@ -19,8 +19,9 @@ const branches: GitBranch[] = [
1919
20describe("deriveSyncedLocalBranch", () => {20describe("deriveSyncedLocalBranch", () => {
21 it("syncs to git current branch in local mode", () => {21 it("syncs to git current branch in local mode", () => {
22 const threadId = ThreadId.makeUnsafe("thread-1");
22 const result = deriveSyncedLocalBranch({23 const result = deriveSyncedLocalBranch({
23 activeThreadId: "thread-1",24 activeThreadId: threadId,
24 activeWorktreePath: null,25 activeWorktreePath: null,
25 envMode: "local",26 envMode: "local",
26 activeThreadBranch: "feature/demo",27 activeThreadBranch: "feature/demo",
...@@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => {...@@ -31,8 +32,9 @@ describe("deriveSyncedLocalBranch", () => {
31 });32 });
3233
33 it("does not sync when creating a new worktree", () => {34 it("does not sync when creating a new worktree", () => {
35 const threadId = ThreadId.makeUnsafe("thread-1");
34 const result = deriveSyncedLocalBranch({36 const result = deriveSyncedLocalBranch({
35 activeThreadId: "thread-1",37 activeThreadId: threadId,
36 activeWorktreePath: null,38 activeWorktreePath: null,
37 envMode: "worktree",39 envMode: "worktree",
38 activeThreadBranch: "feature/demo",40 activeThreadBranch: "feature/demo",
...@@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => {...@@ -43,8 +45,9 @@ describe("deriveSyncedLocalBranch", () => {
43 });45 });
4446
45 it("does not sync when thread already targets a worktree path", () => {47 it("does not sync when thread already targets a worktree path", () => {
48 const threadId = ThreadId.makeUnsafe("thread-1");
46 const result = deriveSyncedLocalBranch({49 const result = deriveSyncedLocalBranch({
47 activeThreadId: "thread-1",50 activeThreadId: threadId,
48 activeWorktreePath: "/tmp/repo/worktrees/feature-demo",51 activeWorktreePath: "/tmp/repo/worktrees/feature-demo",
49 envMode: "local",52 envMode: "local",
50 activeThreadBranch: "feature/demo",53 activeThreadBranch: "feature/demo",
apps/web/src/components/BranchToolbar.logic.ts+2-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch, ThreadId } from "@t3tools/contracts";
22
3interface DeriveSyncedLocalBranchInput {3interface DeriveSyncedLocalBranchInput {
4 activeThreadId: string | undefined;4 activeThreadId: ThreadId | undefined;
5 activeWorktreePath: string | null;5 activeWorktreePath: string | null;
6 envMode: "local" | "worktree";6 envMode: "local" | "worktree";
7 activeThreadBranch: string | null;7 activeThreadBranch: string | null;
apps/web/src/components/BranchToolbar.tsx+14-12
...@@ -1,14 +1,14 @@...@@ -1,14 +1,14 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useState } from "react";3import { useEffect, useState } from "react";
44
5import { useNativeApi } from "../hooks/useNativeApi";5import { newCommandId } from "../lib/orchestrationIds";
6import { asThreadId, newCommandId } from "../lib/orchestrationIds";
7import {6import {
8 gitBranchesQueryOptions,7 gitBranchesQueryOptions,
9 gitCheckoutMutationOptions,8 gitCheckoutMutationOptions,
10 gitCreateBranchAndCheckoutMutationOptions,9 gitCreateBranchAndCheckoutMutationOptions,
11} from "../lib/gitReactQuery";10} from "../lib/gitReactQuery";
11import { readNativeApi } from "../nativeApi";
12import { useStore } from "../store";12import { useStore } from "../store";
13import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";13import { deriveSyncedLocalBranch } from "./BranchToolbar.logic";
14import { Button } from "./ui/button";14import { Button } from "./ui/button";
...@@ -24,7 +24,7 @@ import {...@@ -24,7 +24,7 @@ import {
24import { ChevronDownIcon } from "lucide-react";24import { ChevronDownIcon } from "lucide-react";
2525
26interface BranchToolbarProps {26interface BranchToolbarProps {
27 threadId: string;27 threadId: ThreadId;
28 envMode: "local" | "worktree";28 envMode: "local" | "worktree";
29 onEnvModeChange: (mode: "local" | "worktree") => void;29 onEnvModeChange: (mode: "local" | "worktree") => void;
30 envLocked: boolean;30 envLocked: boolean;
...@@ -39,7 +39,6 @@ export default function BranchToolbar({...@@ -39,7 +39,6 @@ export default function BranchToolbar({
39 onComposerFocusRequest,39 onComposerFocusRequest,
40}: BranchToolbarProps) {40}: BranchToolbarProps) {
41 const { state, dispatch } = useStore();41 const { state, dispatch } = useStore();
42 const api = useNativeApi();
43 const queryClient = useQueryClient();42 const queryClient = useQueryClient();
4443
45 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);44 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
...@@ -54,7 +53,7 @@ export default function BranchToolbar({...@@ -54,7 +53,7 @@ export default function BranchToolbar({
5453
55 // ── Queries ───────────────────────────────────────────────────────────54 // ── Queries ───────────────────────────────────────────────────────────
5655
57 const branchesQuery = useQuery(gitBranchesQueryOptions(api, branchCwd));56 const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd));
5857
59 const branches = branchesQuery.data?.branches ?? [];58 const branches = branchesQuery.data?.branches ?? [];
60 const branchNames = branches.map((branch) => branch.name);59 const branchNames = branches.map((branch) => branch.name);
...@@ -72,11 +71,11 @@ export default function BranchToolbar({...@@ -72,11 +71,11 @@ export default function BranchToolbar({
72 // ── Mutations ─────────────────────────────────────────────────────────71 // ── Mutations ─────────────────────────────────────────────────────────
7372
74 const checkoutMutation = useMutation(73 const checkoutMutation = useMutation(
75 gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),74 gitCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
76 );75 );
7776
78 const createBranchMutation = useMutation(77 const createBranchMutation = useMutation(
79 gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }),78 gitCreateBranchAndCheckoutMutationOptions({ cwd: branchCwd, queryClient }),
80 );79 );
8180
82 // ── Effects ───────────────────────────────────────────────────────────81 // ── Effects ───────────────────────────────────────────────────────────
...@@ -92,12 +91,13 @@ export default function BranchToolbar({...@@ -92,12 +91,13 @@ export default function BranchToolbar({
92 queryBranches,91 queryBranches,
93 });92 });
94 if (!activeThreadId || !syncedBranch) return;93 if (!activeThreadId || !syncedBranch) return;
94 const api = readNativeApi();
9595
96 if (api) {96 if (api) {
97 void api.orchestration.dispatchCommand({97 void api.orchestration.dispatchCommand({
98 type: "thread.meta.update",98 type: "thread.meta.update",
99 commandId: newCommandId(),99 commandId: newCommandId(),
100 threadId: asThreadId(activeThreadId),100 threadId: activeThreadId,
101 branch: syncedBranch,101 branch: syncedBranch,
102 worktreePath: null,102 worktreePath: null,
103 });103 });
...@@ -115,7 +115,6 @@ export default function BranchToolbar({...@@ -115,7 +115,6 @@ export default function BranchToolbar({
115 queryBranches,115 queryBranches,
116 envMode,116 envMode,
117 dispatch,117 dispatch,
118 api,
119 ]);118 ]);
120119
121 useEffect(() => {120 useEffect(() => {
...@@ -132,6 +131,7 @@ export default function BranchToolbar({...@@ -132,6 +131,7 @@ export default function BranchToolbar({
132131
133 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {132 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
134 if (!activeThreadId) return;133 if (!activeThreadId) return;
134 const api = readNativeApi();
135 // If the effective cwd is about to change, stop the running session so the135 // If the effective cwd is about to change, stop the running session so the
136 // next message creates a new one with the correct cwd.136 // next message creates a new one with the correct cwd.
137 const sessionId = activeThread?.session?.sessionId;137 const sessionId = activeThread?.session?.sessionId;
...@@ -140,7 +140,7 @@ export default function BranchToolbar({...@@ -140,7 +140,7 @@ export default function BranchToolbar({
140 .dispatchCommand({140 .dispatchCommand({
141 type: "thread.session.stop",141 type: "thread.session.stop",
142 commandId: newCommandId(),142 commandId: newCommandId(),
143 threadId: asThreadId(activeThreadId),143 threadId: activeThreadId,
144 createdAt: new Date().toISOString(),144 createdAt: new Date().toISOString(),
145 })145 })
146 .catch(() => undefined);146 .catch(() => undefined);
...@@ -149,7 +149,7 @@ export default function BranchToolbar({...@@ -149,7 +149,7 @@ export default function BranchToolbar({
149 void api.orchestration.dispatchCommand({149 void api.orchestration.dispatchCommand({
150 type: "thread.meta.update",150 type: "thread.meta.update",
151 commandId: newCommandId(),151 commandId: newCommandId(),
152 threadId: asThreadId(activeThreadId),152 threadId: activeThreadId,
153 branch,153 branch,
154 worktreePath,154 worktreePath,
155 });155 });
...@@ -158,6 +158,7 @@ export default function BranchToolbar({...@@ -158,6 +158,7 @@ export default function BranchToolbar({
158 };158 };
159159
160 const selectBranch = (branch: GitBranch) => {160 const selectBranch = (branch: GitBranch) => {
161 const api = readNativeApi();
161 if (!api || !activeThreadId || !branchCwd) return;162 if (!api || !activeThreadId || !branchCwd) return;
162163
163 // For new worktree mode, selecting a branch picks the base branch.164 // For new worktree mode, selecting a branch picks the base branch.
...@@ -198,6 +199,7 @@ export default function BranchToolbar({...@@ -198,6 +199,7 @@ export default function BranchToolbar({
198199
199 const createBranch = (rawName: string) => {200 const createBranch = (rawName: string) => {
200 const name = rawName.trim();201 const name = rawName.trim();
202 const api = readNativeApi();
201 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;203 if (!api || !activeThreadId || !branchCwd || !name || createBranchMutation.isPending) return;
202 createBranchMutation.mutate(name, {204 createBranchMutation.mutate(name, {
203 onSuccess: () => {205 onSuccess: () => {
apps/web/src/components/ChatMarkdown.tsx+9-5
...@@ -3,7 +3,7 @@ import type { Components } from "react-markdown";...@@ -3,7 +3,7 @@ import type { Components } from "react-markdown";
3import ReactMarkdown from "react-markdown";3import ReactMarkdown from "react-markdown";
4import rehypeHighlight from "rehype-highlight";4import rehypeHighlight from "rehype-highlight";
5import remarkGfm from "remark-gfm";5import remarkGfm from "remark-gfm";
6import { useNativeApi } from "../hooks/useNativeApi";6import { readNativeApi } from "../nativeApi";
7import { resolveMarkdownFileLinkTarget } from "../markdown-links";7import { resolveMarkdownFileLinkTarget } from "../markdown-links";
8import { preferredTerminalEditor } from "../terminal-links";8import { preferredTerminalEditor } from "../terminal-links";
99
...@@ -13,12 +13,11 @@ interface ChatMarkdownProps {...@@ -13,12 +13,11 @@ interface ChatMarkdownProps {
13}13}
1414
15function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {15function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {
16 const api = useNativeApi();
17 const markdownComponents = useMemo<Components>(16 const markdownComponents = useMemo<Components>(
18 () => ({17 () => ({
19 a({ node: _node, href, ...props }) {18 a({ node: _node, href, ...props }) {
20 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);19 const targetPath = resolveMarkdownFileLinkTarget(href, cwd);
21 if (!targetPath || !api) {20 if (!targetPath) {
22 return <a {...props} href={href} target="_blank" rel="noreferrer" />;21 return <a {...props} href={href} target="_blank" rel="noreferrer" />;
23 }22 }
2423
...@@ -29,13 +28,18 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {...@@ -29,13 +28,18 @@ function ChatMarkdown({ text, cwd }: ChatMarkdownProps) {
29 onClick={(event) => {28 onClick={(event) => {
30 event.preventDefault();29 event.preventDefault();
31 event.stopPropagation();30 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 }
33 }}37 }}
34 />38 />
35 );39 );
36 },40 },
37 }),41 }),
38 [api, cwd],42 [cwd],
39 );43 );
4044
41 return (45 return (
apps/web/src/components/ChatView.tsx+101-98
...@@ -1,15 +1,25 @@...@@ -1,15 +1,25 @@
1import {1import {
2 type ApprovalRequestId,
3 DEFAULT_MODEL,
4 DEFAULT_REASONING,
2 EDITORS,5 EDITORS,
3 type EditorId,6 type EditorId,
4 type KeybindingCommand,7 type KeybindingCommand,
5 type NativeApi,8 type MessageId,
9 MODEL_OPTIONS,
10 type ProjectId,
6 type ProjectEntry,11 type ProjectEntry,
7 type ProjectScript,12 type ProjectScript,
8 ModelSlug,13 ModelSlug,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
10 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,15 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
16 REASONING_OPTIONS,
17 type ReasoningEffort,
11 type ResolvedKeybindingsConfig,18 type ResolvedKeybindingsConfig,
12 type ProviderApprovalDecision,19 type ProviderApprovalDecision,
20 type ThreadId,
21 type TurnId,
22 resolveModelSlug,
13} from "@t3tools/contracts";23} from "@t3tools/contracts";
14import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";24import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
15import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";25import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -27,14 +37,6 @@ import {...@@ -27,14 +37,6 @@ import {
27 detectComposerTrigger,37 detectComposerTrigger,
28 replaceTextRange,38 replaceTextRange,
29} from "../composer-logic";39} from "../composer-logic";
30import {
31 DEFAULT_MODEL,
32 DEFAULT_REASONING,
33 MODEL_OPTIONS,
34 REASONING_OPTIONS,
35 ReasoningEffort,
36 resolveModelSlug,
37} from "../model-logic";
38import {40import {
39 derivePendingApprovals,41 derivePendingApprovals,
40 derivePhase,42 derivePhase,
...@@ -66,7 +68,6 @@ import {...@@ -66,7 +68,6 @@ import {
66} from "../keybindings";68} from "../keybindings";
67import ChatMarkdown from "./ChatMarkdown";69import ChatMarkdown from "./ChatMarkdown";
68import ThreadTerminalDrawer from "./ThreadTerminalDrawer";70import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
69import { useNativeApi } from "../hooks/useNativeApi";
70import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";71import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
71import {72import {
72 BotIcon,73 BotIcon,
...@@ -101,13 +102,8 @@ import {...@@ -101,13 +102,8 @@ import {
101} from "~/projectScripts";102} from "~/projectScripts";
102import { Toggle } from "./ui/toggle";103import { Toggle } from "./ui/toggle";
103import { SidebarTrigger } from "./ui/sidebar";104import { SidebarTrigger } from "./ui/sidebar";
104import {105import { newCommandId, newMessageId } from "~/lib/orchestrationIds";
105 asApprovalRequestId,106import { readNativeApi } from "~/nativeApi";
106 asProjectId,
107 asThreadId,
108 newCommandId,
109 newMessageId,
110} from "~/lib/orchestrationIds";
111107
112function formatMessageMeta(createdAt: string, duration: string | null): string {108function formatMessageMeta(createdAt: string, duration: string | null): string {
113 if (!duration) return formatTimestamp(createdAt);109 if (!duration) return formatTimestamp(createdAt);
...@@ -326,7 +322,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -326,7 +322,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
326});322});
327323
328interface ChatViewProps {324interface ChatViewProps {
329 threadId: string;325 threadId: ThreadId;
330}326}
331327
332export default function ChatView({ threadId }: ChatViewProps) {328export default function ChatView({ threadId }: ChatViewProps) {
...@@ -336,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -336,12 +332,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
336 strict: false,332 strict: false,
337 select: (params) => parseDiffRouteSearch(params),333 select: (params) => parseDiffRouteSearch(params),
338 });334 });
339 const api = useNativeApi();
340 const { resolvedTheme } = useTheme();335 const { resolvedTheme } = useTheme();
341 const queryClient = useQueryClient();336 const queryClient = useQueryClient();
342 const createWorktreeMutation = useMutation(337 const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient }));
343 gitCreateWorktreeMutationOptions({ api, queryClient }),
344 );
345 const [prompt, setPrompt] = useState("");338 const [prompt, setPrompt] = useState("");
346 const promptRef = useRef(prompt);339 const promptRef = useRef(prompt);
347 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);340 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
...@@ -353,7 +346,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -353,7 +346,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
353 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);346 const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING);
354 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");347 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
355 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);348 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
356 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);349 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
357 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});350 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
358 const [nowTick, setNowTick] = useState(() => Date.now());351 const [nowTick, setNowTick] = useState(() => Date.now());
359 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);352 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
...@@ -429,7 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -429,7 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
429 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =422 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
430 useTurnDiffSummaries(activeThread);423 useTurnDiffSummaries(activeThread);
431 const turnDiffSummaryByAssistantMessageId = useMemo(() => {424 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
432 const byMessageId = new Map<string, TurnDiffSummary>();425 const byMessageId = new Map<MessageId, TurnDiffSummary>();
433 for (const summary of turnDiffSummaries) {426 for (const summary of turnDiffSummaries) {
434 if (!summary.assistantMessageId) continue;427 if (!summary.assistantMessageId) continue;
435 byMessageId.set(summary.assistantMessageId, summary);428 byMessageId.set(summary.assistantMessageId, summary);
...@@ -437,7 +430,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -437,7 +430,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
437 return byMessageId;430 return byMessageId;
438 }, [turnDiffSummaries]);431 }, [turnDiffSummaries]);
439 const revertTurnCountByUserMessageId = useMemo(() => {432 const revertTurnCountByUserMessageId = useMemo(() => {
440 const byUserMessageId = new Map<string, number>();433 const byUserMessageId = new Map<MessageId, number>();
441 for (let index = 0; index < timelineEntries.length; index += 1) {434 for (let index = 0; index < timelineEntries.length; index += 1) {
442 const entry = timelineEntries[index];435 const entry = timelineEntries[index];
443 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {436 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
...@@ -536,14 +529,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -536,14 +529,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
536 (debouncerState) => ({ isPending: debouncerState.isPending }),529 (debouncerState) => ({ isPending: debouncerState.isPending }),
537 );530 );
538 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";531 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
539 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));532 const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
540 const keybindingsQuery = useQuery({533 const keybindingsQuery = useQuery({
541 ...serverConfigQueryOptions(api),534 ...serverConfigQueryOptions(),
542 select: (config) => config.keybindings,535 select: (config) => config.keybindings,
543 });536 });
544 const workspaceEntriesQuery = useQuery(537 const workspaceEntriesQuery = useQuery(
545 projectSearchEntriesQueryOptions({538 projectSearchEntriesQueryOptions({
546 api,
547 cwd: gitCwd,539 cwd: gitCwd,
548 query: effectivePathQuery,540 query: effectivePathQuery,
549 enabled: isPathTrigger,541 enabled: isPathTrigger,
...@@ -689,6 +681,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -689,6 +681,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
689 );681 );
690 const closeTerminal = useCallback(682 const closeTerminal = useCallback(
691 (terminalId: string) => {683 (terminalId: string) => {
684 const api = readNativeApi();
692 if (!activeThreadId || !api) return;685 if (!activeThreadId || !api) return;
693 const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;686 const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;
694 const fallbackExitWrite = () =>687 const fallbackExitWrite = () =>
...@@ -714,7 +707,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -714,7 +707,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
714 });707 });
715 setTerminalFocusRequestId((value) => value + 1);708 setTerminalFocusRequestId((value) => value + 1);
716 },709 },
717 [activeThread?.terminalIds.length, activeThreadId, api, dispatch],710 [activeThread?.terminalIds.length, activeThreadId, dispatch],
718 );711 );
719 const runProjectScript = useCallback(712 const runProjectScript = useCallback(
720 async (713 async (
...@@ -727,6 +720,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -727,6 +720,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
727 rememberAsLastInvoked?: boolean;720 rememberAsLastInvoked?: boolean;
728 },721 },
729 ) => {722 ) => {
723 const api = readNativeApi();
730 if (!api || !activeThreadId || !activeProject || !activeThread) return;724 if (!api || !activeThreadId || !activeProject || !activeThread) return;
731 if (options?.rememberAsLastInvoked !== false) {725 if (options?.rememberAsLastInvoked !== false) {
732 setLastInvokedScriptByProjectId((current) => {726 setLastInvokedScriptByProjectId((current) => {
...@@ -799,23 +793,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -799,23 +793,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
799 });793 });
800 }794 }
801 },795 },
802 [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd],796 [activeProject, activeThread, activeThreadId, dispatch, gitCwd],
803 );797 );
804 const persistProjectScripts = useCallback(798 const persistProjectScripts = useCallback(
805 async (input: {799 async (input: {
806 projectId: string;800 projectId: ProjectId;
807 projectCwd: string;801 projectCwd: string;
808 previousScripts: ProjectScript[];802 previousScripts: ProjectScript[];
809 nextScripts: ProjectScript[];803 nextScripts: ProjectScript[];
810 keybinding?: string | null;804 keybinding?: string | null;
811 keybindingCommand: KeybindingCommand;805 keybindingCommand: KeybindingCommand;
812 }) => {806 }) => {
807 const api = readNativeApi();
813 if (!api) return;808 if (!api) return;
814809
815 await api.orchestration.dispatchCommand({810 await api.orchestration.dispatchCommand({
816 type: "project.meta.update",811 type: "project.meta.update",
817 commandId: newCommandId(),812 commandId: newCommandId(),
818 projectId: asProjectId(input.projectId),813 projectId: input.projectId,
819 scripts: input.nextScripts,814 scripts: input.nextScripts,
820 });815 });
821816
...@@ -836,7 +831,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -836,7 +831,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
836 );831 );
837 }832 }
838 },833 },
839 [api, queryClient],834 [queryClient],
840 );835 );
841 const saveProjectScript = useCallback(836 const saveProjectScript = useCallback(
842 async (input: NewProjectScriptInput) => {837 async (input: NewProjectScriptInput) => {
...@@ -911,6 +906,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -911,6 +906,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
911 if (mode === state.runtimeMode) return;906 if (mode === state.runtimeMode) return;
912 dispatch({ type: "SET_RUNTIME_MODE", mode });907 dispatch({ type: "SET_RUNTIME_MODE", mode });
913 scheduleComposerFocus();908 scheduleComposerFocus();
909 const api = readNativeApi();
914 if (!api) return;910 if (!api) return;
915911
916 const runningThreadIds = state.threads912 const runningThreadIds = state.threads
...@@ -927,7 +923,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -927,7 +923,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
927 .dispatchCommand({923 .dispatchCommand({
928 type: "thread.session.stop",924 type: "thread.session.stop",
929 commandId: newCommandId(),925 commandId: newCommandId(),
930 threadId: asThreadId(threadId),926 threadId,
931 createdAt: new Date().toISOString(),927 createdAt: new Date().toISOString(),
932 })928 })
933 .catch(() => undefined),929 .catch(() => undefined),
...@@ -1182,7 +1178,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1182,7 +1178,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1182 ]);1178 ]);
11831179
1184 const setThreadError = useCallback(1180 const setThreadError = useCallback(
1185 (threadId: string | null, error: string | null) => {1181 (threadId: ThreadId | null, error: string | null) => {
1186 if (!threadId) return;1182 if (!threadId) return;
1187 dispatch({1183 dispatch({
1188 type: "SET_ERROR",1184 type: "SET_ERROR",
...@@ -1298,9 +1294,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1298,9 +1294,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
12981294
1299 const onRevertToTurnCount = useCallback(1295 const onRevertToTurnCount = useCallback(
1300 async (turnCount: number) => {1296 async (turnCount: number) => {
1301 if (!api || !activeThread || isRevertingCheckpoint) {1297 const api = readNativeApi();
1302 return;1298 if (!api || !activeThread || isRevertingCheckpoint) return;
1303 }1299
1304 if (phase === "running" || isSending || isConnecting) {1300 if (phase === "running" || isSending || isConnecting) {
1305 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");1301 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
1306 return;1302 return;
...@@ -1322,7 +1318,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1322,7 +1318,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1322 await api.orchestration.dispatchCommand({1318 await api.orchestration.dispatchCommand({
1323 type: "thread.checkpoint.revert",1319 type: "thread.checkpoint.revert",
1324 commandId: newCommandId(),1320 commandId: newCommandId(),
1325 threadId: asThreadId(activeThread.id),1321 threadId: activeThread.id,
1326 turnCount,1322 turnCount,
1327 createdAt: new Date().toISOString(),1323 createdAt: new Date().toISOString(),
1328 });1324 });
...@@ -1335,11 +1331,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1335,11 +1331,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1335 setIsRevertingCheckpoint(false);1331 setIsRevertingCheckpoint(false);
1336 }1332 }
1337 },1333 },
1338 [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],1334 [activeThread, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError],
1339 );1335 );
13401336
1341 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {1337 const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => {
1342 e.preventDefault();1338 e.preventDefault();
1339 const api = readNativeApi();
1343 if (!api || !activeThread || isSending || isConnecting) return;1340 if (!api || !activeThread || isSending || isConnecting) return;
1344 const trimmed = prompt.trim();1341 const trimmed = prompt.trim();
1345 if (!trimmed && composerImages.length === 0) return;1342 if (!trimmed && composerImages.length === 0) return;
...@@ -1363,7 +1360,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1363,7 +1360,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1363 await api.orchestration.dispatchCommand({1360 await api.orchestration.dispatchCommand({
1364 type: "thread.meta.update",1361 type: "thread.meta.update",
1365 commandId: newCommandId(),1362 commandId: newCommandId(),
1366 threadId: asThreadId(activeThread.id),1363 threadId: activeThread.id,
1367 branch: result.worktree.branch,1364 branch: result.worktree.branch,
1368 worktreePath: result.worktree.path,1365 worktreePath: result.worktree.path,
1369 });1366 });
...@@ -1397,7 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1397,7 +1394,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1397 await api.orchestration.dispatchCommand({1394 await api.orchestration.dispatchCommand({
1398 type: "thread.meta.update",1395 type: "thread.meta.update",
1399 commandId: newCommandId(),1396 commandId: newCommandId(),
1400 threadId: asThreadId(activeThread.id),1397 threadId: activeThread.id,
1401 title,1398 title,
1402 });1399 });
1403 }1400 }
...@@ -1434,7 +1431,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1434,7 +1431,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1434 await api.orchestration.dispatchCommand({1431 await api.orchestration.dispatchCommand({
1435 type: "thread.turn.start",1432 type: "thread.turn.start",
1436 commandId: newCommandId(),1433 commandId: newCommandId(),
1437 threadId: asThreadId(activeThread.id),1434 threadId: activeThread.id,
1438 message: {1435 message: {
1439 messageId: newMessageId(),1436 messageId: newMessageId(),
1440 role: "user",1437 role: "user",
...@@ -1456,17 +1453,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1456,17 +1453,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
1456 };1453 };
14571454
1458 const onInterrupt = async () => {1455 const onInterrupt = async () => {
1456 const api = readNativeApi();
1459 if (!api || !activeThread) return;1457 if (!api || !activeThread) return;
1460 await api.orchestration.dispatchCommand({1458 await api.orchestration.dispatchCommand({
1461 type: "thread.turn.interrupt",1459 type: "thread.turn.interrupt",
1462 commandId: newCommandId(),1460 commandId: newCommandId(),
1463 threadId: asThreadId(activeThread.id),1461 threadId: activeThread.id,
1464 createdAt: new Date().toISOString(),1462 createdAt: new Date().toISOString(),
1465 });1463 });
1466 };1464 };
14671465
1468 const onRespondToApproval = useCallback(1466 const onRespondToApproval = useCallback(
1469 async (requestId: string, decision: ProviderApprovalDecision) => {1467 async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => {
1468 const api = readNativeApi();
1470 if (!api || !activeThreadId) return;1469 if (!api || !activeThreadId) return;
14711470
1472 setRespondingRequestIds((existing) =>1471 setRespondingRequestIds((existing) =>
...@@ -1476,8 +1475,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1476,8 +1475,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1476 await api.orchestration.dispatchCommand({1475 await api.orchestration.dispatchCommand({
1477 type: "thread.approval.respond",1476 type: "thread.approval.respond",
1478 commandId: newCommandId(),1477 commandId: newCommandId(),
1479 threadId: asThreadId(activeThreadId),1478 threadId: activeThreadId,
1480 requestId: asApprovalRequestId(requestId),1479 requestId,
1481 decision,1480 decision,
1482 createdAt: new Date().toISOString(),1481 createdAt: new Date().toISOString(),
1483 });1482 });
...@@ -1491,23 +1490,24 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1491,23 +1490,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
1491 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));1490 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
1492 }1491 }
1493 },1492 },
1494 [activeThreadId, api, dispatch],1493 [activeThreadId, dispatch],
1495 );1494 );
14961495
1497 const onModelSelect = useCallback(1496 const onModelSelect = useCallback(
1498 (model: ModelSlug) => {1497 (model: ModelSlug) => {
1498 const api = readNativeApi();
1499 if (!activeThread) return;1499 if (!activeThread) return;
1500 if (api) {1500 if (api) {
1501 void api.orchestration.dispatchCommand({1501 void api.orchestration.dispatchCommand({
1502 type: "thread.meta.update",1502 type: "thread.meta.update",
1503 commandId: newCommandId(),1503 commandId: newCommandId(),
1504 threadId: asThreadId(activeThread.id),1504 threadId: activeThread.id,
1505 model: resolveModelSlug(model),1505 model: resolveModelSlug(model),
1506 });1506 });
1507 }1507 }
1508 scheduleComposerFocus();1508 scheduleComposerFocus();
1509 },1509 },
1510 [activeThread, api, scheduleComposerFocus],1510 [activeThread, scheduleComposerFocus],
1511 );1511 );
1512 const onEffortSelect = useCallback(1512 const onEffortSelect = useCallback(
1513 (effort: ReasoningEffort) => {1513 (effort: ReasoningEffort) => {
...@@ -1629,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1629,7 +1629,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1629 });1629 });
1630 }, [diffOpen, navigate, threadId]);1630 }, [diffOpen, navigate, threadId]);
1631 const onOpenTurnDiff = useCallback(1631 const onOpenTurnDiff = useCallback(
1632 (turnId: string, filePath?: string) => {1632 (turnId: TurnId, filePath?: string) => {
1633 void navigate({1633 void navigate({
1634 to: "/$threadId",1634 to: "/$threadId",
1635 params: { threadId },1635 params: { threadId },
...@@ -1644,7 +1644,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1644,7 +1644,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1644 [navigate, threadId],1644 [navigate, threadId],
1645 );1645 );
1646 const onRevertUserMessage = useCallback(1646 const onRevertUserMessage = useCallback(
1647 (messageId: string) => {1647 (messageId: MessageId) => {
1648 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);1648 const targetTurnCount = revertTurnCountByUserMessageId.get(messageId);
1649 if (typeof targetTurnCount !== "number") {1649 if (typeof targetTurnCount !== "number") {
1650 return;1650 return;
...@@ -1698,7 +1698,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1698,7 +1698,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1698 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null1698 activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null
1699 }1699 }
1700 keybindings={keybindings}1700 keybindings={keybindings}
1701 api={api}
1702 gitCwd={gitCwd}1701 gitCwd={gitCwd}
1703 diffOpen={diffOpen}1702 diffOpen={diffOpen}
1704 onRunProjectScript={(script) => {1703 onRunProjectScript={(script) => {
...@@ -1971,35 +1970,39 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1971,35 +1970,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
1971 />1970 />
1972 )}1971 )}
19731972
1974 {activeThread.terminalOpen && api && activeProject && (1973 {(() => {
1975 <ThreadTerminalDrawer1974 if (!activeThread.terminalOpen || !activeProject) {
1976 key={activeThread.id}1975 return null;
1977 api={api}1976 }
1978 threadId={activeThread.id}1977 return (
1979 cwd={gitCwd ?? activeProject.cwd}1978 <ThreadTerminalDrawer
1980 runtimeEnv={threadTerminalRuntimeEnv}1979 key={activeThread.id}
1981 height={activeThread.terminalHeight}1980 threadId={activeThread.id}
1982 terminalIds={activeThread.terminalIds}1981 cwd={gitCwd ?? activeProject.cwd}
1983 activeTerminalId={activeThread.activeTerminalId}1982 runtimeEnv={threadTerminalRuntimeEnv}
1984 terminalGroups={activeThread.terminalGroups}1983 height={activeThread.terminalHeight}
1985 activeTerminalGroupId={activeThread.activeTerminalGroupId}1984 terminalIds={activeThread.terminalIds}
1986 focusRequestId={terminalFocusRequestId}1985 activeTerminalId={activeThread.activeTerminalId}
1987 onSplitTerminal={splitTerminal}1986 terminalGroups={activeThread.terminalGroups}
1988 onNewTerminal={createNewTerminal}1987 activeTerminalGroupId={activeThread.activeTerminalGroupId}
1989 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}1988 focusRequestId={terminalFocusRequestId}
1990 newShortcutLabel={newTerminalShortcutLabel ?? undefined}1989 onSplitTerminal={splitTerminal}
1991 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}1990 onNewTerminal={createNewTerminal}
1992 onActiveTerminalChange={activateTerminal}1991 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
1993 onCloseTerminal={closeTerminal}1992 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
1994 onHeightChange={(height) =>1993 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
1995 dispatch({1994 onActiveTerminalChange={activateTerminal}
1996 type: "SET_THREAD_TERMINAL_HEIGHT",1995 onCloseTerminal={closeTerminal}
1997 threadId: activeThread.id,1996 onHeightChange={(height) =>
1998 height,1997 dispatch({
1999 })1998 type: "SET_THREAD_TERMINAL_HEIGHT",
2000 }1999 threadId: activeThread.id,
2001 />2000 height,
2002 )}2001 })
2002 }
2003 />
2004 );
2005 })()}
20032006
2004 {expandedImage && (2007 {expandedImage && (
2005 <div2008 <div
...@@ -2040,13 +2043,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2040,13 +2043,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2040}2043}
20412044
2042interface ChatHeaderProps {2045interface ChatHeaderProps {
2043 activeThreadId: string;2046 activeThreadId: ThreadId;
2044 activeThreadTitle: string;2047 activeThreadTitle: string;
2045 activeProjectName: string | undefined;2048 activeProjectName: string | undefined;
2046 activeProjectScripts: ProjectScript[] | undefined;2049 activeProjectScripts: ProjectScript[] | undefined;
2047 preferredScriptId: string | null;2050 preferredScriptId: string | null;
2048 keybindings: ResolvedKeybindingsConfig;2051 keybindings: ResolvedKeybindingsConfig;
2049 api: NativeApi | undefined;
2050 gitCwd: string | null;2052 gitCwd: string | null;
2051 diffOpen: boolean;2053 diffOpen: boolean;
2052 onRunProjectScript: (script: ProjectScript) => void;2054 onRunProjectScript: (script: ProjectScript) => void;
...@@ -2062,7 +2064,6 @@ const ChatHeader = memo(function ChatHeader({...@@ -2062,7 +2064,6 @@ const ChatHeader = memo(function ChatHeader({
2062 activeProjectScripts,2064 activeProjectScripts,
2063 preferredScriptId,2065 preferredScriptId,
2064 keybindings,2066 keybindings,
2065 api,
2066 gitCwd,2067 gitCwd,
2067 diffOpen,2068 diffOpen,
2068 onRunProjectScript,2069 onRunProjectScript,
...@@ -2100,9 +2101,7 @@ const ChatHeader = memo(function ChatHeader({...@@ -2100,9 +2101,7 @@ const ChatHeader = memo(function ChatHeader({
2100 {activeProjectName && (2101 {activeProjectName && (
2101 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />2102 <OpenInPicker keybindings={keybindings} activeThreadId={activeThreadId} />
2102 )}2103 )}
2103 {activeProjectName && (2104 {activeProjectName && <GitActionsControl gitCwd={gitCwd} activeThreadId={activeThreadId} />}
2104 <GitActionsControl api={api} gitCwd={gitCwd} activeThreadId={activeThreadId} />
2105 )}
2106 <Toggle2105 <Toggle
2107 className="shrink-0"2106 className="shrink-0"
2108 pressed={diffOpen}2107 pressed={diffOpen}
...@@ -2134,8 +2133,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st...@@ -2134,8 +2133,11 @@ const ThreadErrorBanner = memo(function ThreadErrorBanner({ error }: { error: st
21342133
2135interface PendingApprovalsPanelProps {2134interface PendingApprovalsPanelProps {
2136 pendingApprovals: PendingApproval[];2135 pendingApprovals: PendingApproval[];
2137 respondingRequestIds: string[];2136 respondingRequestIds: ApprovalRequestId[];
2138 onRespondToApproval: (requestId: string, decision: ProviderApprovalDecision) => Promise<void>;2137 onRespondToApproval: (
2138 requestId: ApprovalRequestId,
2139 decision: ProviderApprovalDecision,
2140 ) => Promise<void>;
2139}2141}
21402142
2141const PendingApprovalsPanel = memo(function PendingApprovalsPanel({2143const PendingApprovalsPanel = memo(function PendingApprovalsPanel({
...@@ -2211,13 +2213,13 @@ interface MessagesTimelineProps {...@@ -2211,13 +2213,13 @@ interface MessagesTimelineProps {
2211 timelineEntries: ReturnType<typeof deriveTimelineEntries>;2213 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
2212 completionDividerBeforeEntryId: string | null;2214 completionDividerBeforeEntryId: string | null;
2213 completionSummary: string | null;2215 completionSummary: string | null;
2214 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;2216 turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
2215 nowIso: string;2217 nowIso: string;
2216 expandedWorkGroups: Record<string, boolean>;2218 expandedWorkGroups: Record<string, boolean>;
2217 onToggleWorkGroup: (groupId: string) => void;2219 onToggleWorkGroup: (groupId: string) => void;
2218 onOpenTurnDiff: (turnId: string, filePath?: string) => void;2220 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
2219 revertTurnCountByUserMessageId: Map<string, number>;2221 revertTurnCountByUserMessageId: Map<MessageId, number>;
2220 onRevertUserMessage: (messageId: string) => void;2222 onRevertUserMessage: (messageId: MessageId) => void;
2221 isRevertingCheckpoint: boolean;2223 isRevertingCheckpoint: boolean;
2222 onImageExpand: (image: ExpandedImagePreview) => void;2224 onImageExpand: (image: ExpandedImagePreview) => void;
2223 markdownCwd: string | undefined;2225 markdownCwd: string | undefined;
...@@ -2667,7 +2669,7 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -2667,7 +2669,7 @@ const OpenInPicker = memo(function OpenInPicker({
2667 activeThreadId,2669 activeThreadId,
2668}: {2670}: {
2669 keybindings: ResolvedKeybindingsConfig;2671 keybindings: ResolvedKeybindingsConfig;
2670 activeThreadId: string | null;2672 activeThreadId: ThreadId | null;
2671}) {2673}) {
2672 const [lastEditor, setLastEditor] = useState<EditorId>(() => {2674 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
2673 const stored = localStorage.getItem(LAST_EDITOR_KEY);2675 const stored = localStorage.getItem(LAST_EDITOR_KEY);
...@@ -2692,13 +2694,13 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -2692,13 +2694,13 @@ const OpenInPicker = memo(function OpenInPicker({
2692 ] satisfies { label: string; Icon: Icon; value: EditorId }[];2694 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
2693 const primaryOption = options.find(({ value }) => value === lastEditor);2695 const primaryOption = options.find(({ value }) => value === lastEditor);
26942696
2695 const api = useNativeApi();
2696 const { state } = useStore();2697 const { state } = useStore();
2697 const activeThread = state.threads.find((t) => t.id === activeThreadId);2698 const activeThread = state.threads.find((t) => t.id === activeThreadId);
2698 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);2699 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
26992700
2700 const openInEditor = useCallback(2701 const openInEditor = useCallback(
2701 (editorId: EditorId | null) => {2702 (editorId: EditorId | null) => {
2703 const api = readNativeApi();
2702 if (!api || !activeProject) return;2704 if (!api || !activeProject) return;
2703 const editor = editorId ?? lastEditor;2705 const editor = editorId ?? lastEditor;
2704 const cwd = activeThread?.worktreePath ?? activeProject.cwd;2706 const cwd = activeThread?.worktreePath ?? activeProject.cwd;
...@@ -2706,7 +2708,7 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -2706,7 +2708,7 @@ const OpenInPicker = memo(function OpenInPicker({
2706 localStorage.setItem(LAST_EDITOR_KEY, editor);2708 localStorage.setItem(LAST_EDITOR_KEY, editor);
2707 setLastEditor(editor);2709 setLastEditor(editor);
2708 },2710 },
2709 [api, activeProject, activeThread, lastEditor, setLastEditor],2711 [activeProject, activeThread, lastEditor, setLastEditor],
2710 );2712 );
27112713
2712 const openFavoriteEditorShortcutLabel = useMemo(2714 const openFavoriteEditorShortcutLabel = useMemo(
...@@ -2716,6 +2718,7 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -2716,6 +2718,7 @@ const OpenInPicker = memo(function OpenInPicker({
27162718
2717 useEffect(() => {2719 useEffect(() => {
2718 const handler = (e: globalThis.KeyboardEvent) => {2720 const handler = (e: globalThis.KeyboardEvent) => {
2721 const api = readNativeApi();
2719 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;2722 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
2720 if (!api || !activeProject) return;2723 if (!api || !activeProject) return;
27212724
...@@ -2725,7 +2728,7 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -2725,7 +2728,7 @@ const OpenInPicker = memo(function OpenInPicker({
2725 };2728 };
2726 window.addEventListener("keydown", handler);2729 window.addEventListener("keydown", handler);
2727 return () => window.removeEventListener("keydown", handler);2730 return () => window.removeEventListener("keydown", handler);
2728 }, [api, activeProject, activeThread, keybindings, lastEditor]);2731 }, [activeProject, activeThread, keybindings, lastEditor]);
27292732
2730 return (2733 return (
2731 <Group aria-label="Subscription actions">2734 <Group aria-label="Subscription actions">
apps/web/src/components/DiffPanel.tsx+8-11
...@@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs";...@@ -2,13 +2,13 @@ import { parsePatchFiles } from "@pierre/diffs";
2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
3import { useQuery } from "@tanstack/react-query";3import { useQuery } from "@tanstack/react-query";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";
5import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
6import { useCallback, useEffect, useMemo, useRef, useState } from "react";7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";8import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
8import { cn } from "~/lib/utils";9import { cn } from "~/lib/utils";
9import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";10import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
10import { isElectron } from "../env";11import { isElectron } from "../env";
11import { useNativeApi } from "../hooks/useNativeApi";
12import { useTheme } from "../hooks/useTheme";12import { useTheme } from "../hooks/useTheme";
13import { buildPatchCacheKey } from "../lib/diffRendering";13import { buildPatchCacheKey } from "../lib/diffRendering";
14import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";14import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -81,7 +81,6 @@ interface DiffPanelProps {...@@ -81,7 +81,6 @@ interface DiffPanelProps {
81export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";81export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
8282
83export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {83export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
84 const api = useNativeApi();
85 const navigate = useNavigate();84 const navigate = useNavigate();
86 const { resolvedTheme } = useTheme();85 const { resolvedTheme } = useTheme();
87 const { state } = useStore();86 const { state } = useStore();
...@@ -90,13 +89,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -90,13 +89,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
90 const turnStripRef = useRef<HTMLDivElement>(null);89 const turnStripRef = useRef<HTMLDivElement>(null);
91 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);90 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
92 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);91 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
93 const params = useParams({ strict: false });92 const routeThreadId = useParams({
94 const rawSearch = useSearch({ strict: false });93 strict: false,
95 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;94 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
96 const diffSearch = useMemo(95 });
97 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),96 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
98 [rawSearch],
99 );
100 const activeThreadId = routeThreadId;97 const activeThreadId = routeThreadId;
101 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);98 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
102 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =99 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
...@@ -169,7 +166,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -169,7 +166,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
169 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;166 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
170 }, [orderedTurnDiffSummaries, selectedTurn]);167 }, [orderedTurnDiffSummaries, selectedTurn]);
171 const activeCheckpointDiffQuery = useQuery(168 const activeCheckpointDiffQuery = useQuery(
172 checkpointDiffQueryOptions(api, {169 checkpointDiffQueryOptions({
173 threadId: activeThreadId,170 threadId: activeThreadId,
174 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,171 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
175 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,172 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
...@@ -216,7 +213,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -216,7 +213,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
216 target?.scrollIntoView({ block: "nearest" });213 target?.scrollIntoView({ block: "nearest" });
217 }, [selectedFilePath, renderableFiles]);214 }, [selectedFilePath, renderableFiles]);
218215
219 const selectTurn = (turnId: string) => {216 const selectTurn = (turnId: TurnId) => {
220 if (!activeThread) return;217 if (!activeThread) return;
221 void navigate({218 void navigate({
222 to: "/$threadId",219 to: "/$threadId",
apps/web/src/components/GitActionsControl.tsx+19-17
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type GitStatusResult, type GitStackedAction, type NativeApi } from "@t3tools/contracts";1import { type GitStatusResult, type GitStackedAction, type ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useMemo, useState } from "react";3import { useCallback, useEffect, useMemo, useState } from "react";
4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";4import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
...@@ -39,11 +39,11 @@ import {...@@ -39,11 +39,11 @@ import {
39 invalidateGitQueries,39 invalidateGitQueries,
40} from "~/lib/gitReactQuery";40} from "~/lib/gitReactQuery";
41import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";41import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
42import { readNativeApi } from "~/nativeApi";
4243
43interface GitActionsControlProps {44interface GitActionsControlProps {
44 api: NativeApi | undefined;
45 gitCwd: string | null;45 gitCwd: string | null;
46 activeThreadId: string | null;46 activeThreadId: ThreadId | null;
47}47}
4848
49function getMenuActionDisabledReason(49function getMenuActionDisabledReason(
...@@ -136,7 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {...@@ -136,7 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
136 return <InfoIcon className={iconClassName} />;136 return <InfoIcon className={iconClassName} />;
137}137}
138138
139export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) {139export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
140 const threadToastData = useMemo(140 const threadToastData = useMemo(
141 () => (activeThreadId ? { threadId: activeThreadId } : undefined),141 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
142 [activeThreadId],142 [activeThreadId],
...@@ -145,11 +145,9 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -145,11 +145,9 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
145 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);145 const [activeDialogAction, setActiveDialogAction] = useState<GitDialogAction | null>(null);
146 const [dialogCommitMessage, setDialogCommitMessage] = useState("");146 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
147147
148 const { data: gitStatus = null, error: gitStatusError } = useQuery(148 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
149 gitStatusQueryOptions(api, gitCwd),
150 );
151149
152 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(api, gitCwd));150 const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd));
153 // Default to true while loading so we don't flash init controls.151 // Default to true while loading so we don't flash init controls.
154 const isRepo = branchList?.isRepo ?? true;152 const isRepo = branchList?.isRepo ?? true;
155 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;153 const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null;
...@@ -163,12 +161,12 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -163,12 +161,12 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
163161
164 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;162 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
165163
166 const initMutation = useMutation(gitInitMutationOptions({ api, cwd: gitCwd, queryClient }));164 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
167165
168 const runImmediateGitActionMutation = useMutation(166 const runImmediateGitActionMutation = useMutation(
169 gitRunStackedActionMutationOptions({ api, cwd: gitCwd, queryClient }),167 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
170 );168 );
171 const pullMutation = useMutation(gitPullMutationOptions({ api, cwd: gitCwd, queryClient }));169 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
172170
173 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;171 const isGitActionRunning = runImmediateGitActionMutation.isPending || pullMutation.isPending;
174 const isDefaultBranch = useMemo(() => {172 const isDefaultBranch = useMemo(() => {
...@@ -192,6 +190,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -192,6 +190,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
192190
193 const maybeConfirmPushToDefaultBranch = useCallback(191 const maybeConfirmPushToDefaultBranch = useCallback(
194 async (action: GitStackedAction): Promise<boolean> => {192 async (action: GitStackedAction): Promise<boolean> => {
193 const api = readNativeApi();
195 if (!api) return false;194 if (!api) return false;
196 if (195 if (
197 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||196 !requiresDefaultBranchConfirmation(action, isDefaultBranch) ||
...@@ -203,10 +202,11 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -203,10 +202,11 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
203 `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`,202 `You're about to push to the default branch "${gitStatusForActions.branch}". Continue?`,
204 );203 );
205 },204 },
206 [api, gitStatusForActions?.branch, isDefaultBranch],205 [gitStatusForActions?.branch, isDefaultBranch],
207 );206 );
208207
209 const openExistingPr = useCallback(async () => {208 const openExistingPr = useCallback(async () => {
209 const api = readNativeApi();
210 if (!api) {210 if (!api) {
211 toastManager.add({211 toastManager.add({
212 type: "error",212 type: "error",
...@@ -232,8 +232,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -232,8 +232,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
232 data: threadToastData,232 data: threadToastData,
233 });233 });
234 });234 });
235 }, [api, gitStatusForActions?.openPr?.url, threadToastData]);235 }, [gitStatusForActions?.openPr?.url, threadToastData]);
236
237 const runGitActionWithToast = useCallback(236 const runGitActionWithToast = useCallback(
238 async ({237 async ({
239 action,238 action,
...@@ -329,8 +328,10 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -329,8 +328,10 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
329 actionProps: {328 actionProps: {
330 children: "Open PR",329 children: "Open PR",
331 onClick: () => {330 onClick: () => {
331 const api = readNativeApi();
332 if (!api) return;
332 closeResultToast();333 closeResultToast();
333 void api?.shell.openExternal(prUrl);334 void api.shell.openExternal(prUrl);
334 },335 },
335 },336 },
336 }337 }
...@@ -356,8 +357,8 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -356,8 +357,8 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
356 });357 });
357 }358 }
358 },359 },
360
359 [361 [
360 api,
361 gitStatusForActions?.branch,362 gitStatusForActions?.branch,
362 gitStatusForActions?.hasWorkingTreeChanges,363 gitStatusForActions?.hasWorkingTreeChanges,
363 gitStatusForActions?.openPr?.url,364 gitStatusForActions?.openPr?.url,
...@@ -447,6 +448,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -447,6 +448,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
447448
448 const openChangedFileInEditor = useCallback(449 const openChangedFileInEditor = useCallback(
449 (filePath: string) => {450 (filePath: string) => {
451 const api = readNativeApi();
450 if (!api || !gitCwd) {452 if (!api || !gitCwd) {
451 toastManager.add({453 toastManager.add({
452 type: "error",454 type: "error",
...@@ -465,7 +467,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc...@@ -465,7 +467,7 @@ export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitAc
465 });467 });
466 });468 });
467 },469 },
468 [api, gitCwd, threadToastData],470 [gitCwd, threadToastData],
469 );471 );
470472
471 if (!gitCwd) return null;473 if (!gitCwd) return null;
apps/web/src/components/Sidebar.tsx+34-40
...@@ -1,25 +1,23 @@...@@ -1,25 +1,23 @@
1import { ChevronRightIcon, TerminalIcon } from "lucide-react";1import { ChevronRightIcon, TerminalIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { 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";
4import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";9import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5import { useNavigate, useParams } from "@tanstack/react-router";10import { useNavigate, useParams } from "@tanstack/react-router";
6import { useAppSettings } from "../appSettings";11import { useAppSettings } from "../appSettings";
7import { isElectron } from "../env";12import { isElectron } from "../env";
8import { APP_STAGE_LABEL } from "../branding";13import { APP_STAGE_LABEL } from "../branding";
9import { DEFAULT_MODEL } from "../model-logic";14import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
10import {
11 asProjectId,
12 asThreadId,
13 newCommandId,
14 newProjectId,
15 newThreadId,
16} from "../lib/orchestrationIds";
17import { useStore } from "../store";15import { useStore } from "../store";
18import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";16import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
19import { type Thread } from "../types";17import { type Thread } from "../types";
20import { useNativeApi } from "../hooks/useNativeApi";
21import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";18import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
22import { serverConfigQueryOptions } from "../lib/serverReactQuery";19import { serverConfigQueryOptions } from "../lib/serverReactQuery";
20import { readNativeApi } from "../nativeApi";
23import { toastManager } from "./ui/toast";21import { toastManager } from "./ui/toast";
24import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";22import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
25import {23import {
...@@ -37,6 +35,7 @@ import {...@@ -37,6 +35,7 @@ import {
37 SidebarTrigger,35 SidebarTrigger,
38} from "./ui/sidebar";36} from "./ui/sidebar";
39import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";37import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
38import { isNonEmpty as isNonEmptyString } from "effect/String";
4039
41const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];40const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
4241
...@@ -143,33 +142,33 @@ function T3Wordmark() {...@@ -143,33 +142,33 @@ function T3Wordmark() {
143142
144export default function Sidebar() {143export default function Sidebar() {
145 const { state, dispatch } = useStore();144 const { state, dispatch } = useStore();
146 const api = useNativeApi();
147 const navigate = useNavigate();145 const navigate = useNavigate();
148 const { settings: appSettings } = useAppSettings();146 const { settings: appSettings } = useAppSettings();
149 const params = useParams({ strict: false });147 const routeThreadId = useParams({
150 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;148 strict: false,
149 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
150 });
151 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({151 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
152 ...serverConfigQueryOptions(api),152 ...serverConfigQueryOptions(),
153 select: (config) => config.keybindings,153 select: (config) => config.keybindings,
154 });154 });
155 const queryClient = useQueryClient();155 const queryClient = useQueryClient();
156 const removeWorktreeMutation = useMutation(156 const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
157 gitRemoveWorktreeMutationOptions({ api, queryClient }),
158 );
159 const [addingProject, setAddingProject] = useState(false);157 const [addingProject, setAddingProject] = useState(false);
160 const [newCwd, setNewCwd] = useState("");158 const [newCwd, setNewCwd] = useState("");
161 const [isPickingFolder, setIsPickingFolder] = useState(false);159 const [isPickingFolder, setIsPickingFolder] = useState(false);
162 const [isAddingProject, setIsAddingProject] = useState(false);160 const [isAddingProject, setIsAddingProject] = useState(false);
163 const pendingApprovalByThreadId = useMemo(() => new Map<string, boolean>(), []);161 const pendingApprovalByThreadId = useMemo(() => new Map<ThreadId, boolean>(), []);
164162
165 const handleNewThread = useCallback(163 const handleNewThread = useCallback(
166 (164 (
167 projectId: string,165 projectId: ProjectId,
168 options?: {166 options?: {
169 branch?: string | null;167 branch?: string | null;
170 worktreePath?: string | null;168 worktreePath?: string | null;
171 },169 },
172 ): Promise<void> => {170 ): Promise<void> => {
171 const api = readNativeApi();
173 if (!api) return Promise.resolve();172 if (!api) return Promise.resolve();
174 const threadId = newThreadId();173 const threadId = newThreadId();
175 const createdAt = new Date().toISOString();174 const createdAt = new Date().toISOString();
...@@ -180,7 +179,7 @@ export default function Sidebar() {...@@ -180,7 +179,7 @@ export default function Sidebar() {
180 type: "thread.create",179 type: "thread.create",
181 commandId: newCommandId(),180 commandId: newCommandId(),
182 threadId,181 threadId,
183 projectId: asProjectId(projectId),182 projectId,
184 title: "New thread",183 title: "New thread",
185 model,184 model,
186 branch: options?.branch ?? null,185 branch: options?.branch ?? null,
...@@ -202,11 +201,11 @@ export default function Sidebar() {...@@ -202,11 +201,11 @@ export default function Sidebar() {
202 });201 });
203 })();202 })();
204 },203 },
205 [api, dispatch, navigate, state.projects],204 [dispatch, navigate, state.projects],
206 );205 );
207206
208 const focusMostRecentThreadForProject = useCallback(207 const focusMostRecentThreadForProject = useCallback(
209 (projectId: string) => {208 (projectId: ProjectId) => {
210 const latestThread = state.threads209 const latestThread = state.threads
211 .filter((thread) => thread.projectId === projectId)210 .filter((thread) => thread.projectId === projectId)
212 .toSorted((a, b) => {211 .toSorted((a, b) => {
...@@ -228,6 +227,7 @@ export default function Sidebar() {...@@ -228,6 +227,7 @@ export default function Sidebar() {
228 async (rawCwd: string) => {227 async (rawCwd: string) => {
229 const cwd = rawCwd.trim();228 const cwd = rawCwd.trim();
230 if (!cwd || isAddingProject) return;229 if (!cwd || isAddingProject) return;
230 const api = readNativeApi();
231231
232 setIsAddingProject(true);232 setIsAddingProject(true);
233 try {233 try {
...@@ -240,7 +240,7 @@ export default function Sidebar() {...@@ -240,7 +240,7 @@ export default function Sidebar() {
240240
241 const projectId = newProjectId();241 const projectId = newProjectId();
242 const createdAt = new Date().toISOString();242 const createdAt = new Date().toISOString();
243 const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd;243 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
244 await api.orchestration.dispatchCommand({244 await api.orchestration.dispatchCommand({
245 type: "project.create",245 type: "project.create",
246 commandId: newCommandId(),246 commandId: newCommandId(),
...@@ -257,14 +257,7 @@ export default function Sidebar() {...@@ -257,14 +257,7 @@ export default function Sidebar() {
257 setAddingProject(false);257 setAddingProject(false);
258 }258 }
259 },259 },
260 [260 [focusMostRecentThreadForProject, handleNewThread, isAddingProject, state.projects],
261 api,
262 focusMostRecentThreadForProject,
263 handleNewThread,
264 isAddingProject,
265 state.projects,
266 state.threads,
267 ],
268 );261 );
269262
270 const handleAddProject = () => {263 const handleAddProject = () => {
...@@ -272,6 +265,7 @@ export default function Sidebar() {...@@ -272,6 +265,7 @@ export default function Sidebar() {
272 };265 };
273266
274 const handlePickFolder = async () => {267 const handlePickFolder = async () => {
268 const api = readNativeApi();
275 if (!api || isPickingFolder) return;269 if (!api || isPickingFolder) return;
276 setIsPickingFolder(true);270 setIsPickingFolder(true);
277 try {271 try {
...@@ -284,7 +278,8 @@ export default function Sidebar() {...@@ -284,7 +278,8 @@ export default function Sidebar() {
284 };278 };
285279
286 const handleThreadContextMenu = useCallback(280 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();
288 if (!api) return;283 if (!api) return;
289 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);284 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
290 if (clicked !== "delete") return;285 if (clicked !== "delete") return;
...@@ -324,7 +319,7 @@ export default function Sidebar() {...@@ -324,7 +319,7 @@ export default function Sidebar() {
324 .dispatchCommand({319 .dispatchCommand({
325 type: "thread.session.stop",320 type: "thread.session.stop",
326 commandId: newCommandId(),321 commandId: newCommandId(),
327 threadId: asThreadId(threadId),322 threadId,
328 createdAt: new Date().toISOString(),323 createdAt: new Date().toISOString(),
329 })324 })
330 .catch(() => undefined);325 .catch(() => undefined);
...@@ -344,7 +339,7 @@ export default function Sidebar() {...@@ -344,7 +339,7 @@ export default function Sidebar() {
344 await api.orchestration.dispatchCommand({339 await api.orchestration.dispatchCommand({
345 type: "thread.delete",340 type: "thread.delete",
346 commandId: newCommandId(),341 commandId: newCommandId(),
347 threadId: asThreadId(threadId),342 threadId,
348 });343 });
349 if (shouldNavigateToFallback) {344 if (shouldNavigateToFallback) {
350 if (fallbackThreadId) {345 if (fallbackThreadId) {
...@@ -384,9 +379,7 @@ export default function Sidebar() {...@@ -384,9 +379,7 @@ export default function Sidebar() {
384 }379 }
385 },380 },
386 [381 [
387 api,
388 appSettings.confirmThreadDelete,382 appSettings.confirmThreadDelete,
389 dispatch,
390 navigate,383 navigate,
391 removeWorktreeMutation,384 removeWorktreeMutation,
392 routeThreadId,385 routeThreadId,
...@@ -396,7 +389,8 @@ export default function Sidebar() {...@@ -396,7 +389,8 @@ export default function Sidebar() {
396 );389 );
397390
398 const handleProjectContextMenu = useCallback(391 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();
400 if (!api) return;394 if (!api) return;
401 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);395 const clicked = await api.contextMenu.show([{ id: "delete", label: "Delete" }], position);
402 if (clicked !== "delete") return;396 if (clicked !== "delete") return;
...@@ -423,7 +417,7 @@ export default function Sidebar() {...@@ -423,7 +417,7 @@ export default function Sidebar() {
423 await api.orchestration.dispatchCommand({417 await api.orchestration.dispatchCommand({
424 type: "project.delete",418 type: "project.delete",
425 commandId: newCommandId(),419 commandId: newCommandId(),
426 projectId: asProjectId(projectId),420 projectId,
427 });421 });
428 } catch (error) {422 } catch (error) {
429 const message = error instanceof Error ? error.message : "Unknown error deleting project.";423 const message = error instanceof Error ? error.message : "Unknown error deleting project.";
...@@ -435,7 +429,7 @@ export default function Sidebar() {...@@ -435,7 +429,7 @@ export default function Sidebar() {
435 });429 });
436 }430 }
437 },431 },
438 [api, state.projects, state.threads],432 [state.projects, state.threads],
439 );433 );
440434
441 useEffect(() => {435 useEffect(() => {
...@@ -687,7 +681,7 @@ export default function Sidebar() {...@@ -687,7 +681,7 @@ export default function Sidebar() {
687 if (event.key === "Escape") setAddingProject(false);681 if (event.key === "Escape") setAddingProject(false);
688 }}682 }}
689 />683 />
690 {isElectron && api && (684 {isElectron && (
691 <button685 <button
692 type="button"686 type="button"
693 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"687 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 @@...@@ -1,6 +1,6 @@
1import { FitAddon } from "@xterm/addon-fit";1import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
3import { type NativeApi } from "@t3tools/contracts";3import { type ThreadId } from "@t3tools/contracts";
4import { Terminal, type ITheme } from "@xterm/xterm";4import { Terminal, type ITheme } from "@xterm/xterm";
5import {5import {
6 type PointerEvent as ReactPointerEvent,6 type PointerEvent as ReactPointerEvent,
...@@ -25,6 +25,7 @@ import {...@@ -25,6 +25,7 @@ import {
25 MAX_THREAD_TERMINAL_COUNT,25 MAX_THREAD_TERMINAL_COUNT,
26 type ThreadTerminalGroup,26 type ThreadTerminalGroup,
27} from "../types";27} from "../types";
28import { readNativeApi } from "~/nativeApi";
2829
29const MIN_DRAWER_HEIGHT = 180;30const MIN_DRAWER_HEIGHT = 180;
30const MAX_DRAWER_HEIGHT_RATIO = 0.75;31const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -107,8 +108,7 @@ function terminalThemeFromApp(): ITheme {...@@ -107,8 +108,7 @@ function terminalThemeFromApp(): ITheme {
107}108}
108109
109interface TerminalViewportProps {110interface TerminalViewportProps {
110 api: NativeApi;111 threadId: ThreadId;
111 threadId: string;
112 terminalId: string;112 terminalId: string;
113 cwd: string;113 cwd: string;
114 runtimeEnv?: Record<string, string>;114 runtimeEnv?: Record<string, string>;
...@@ -119,7 +119,6 @@ interface TerminalViewportProps {...@@ -119,7 +119,6 @@ interface TerminalViewportProps {
119}119}
120120
121function TerminalViewport({121function TerminalViewport({
122 api,
123 threadId,122 threadId,
124 terminalId,123 terminalId,
125 cwd,124 cwd,
...@@ -155,6 +154,9 @@ function TerminalViewport({...@@ -155,6 +154,9 @@ function TerminalViewport({
155 terminalRef.current = terminal;154 terminalRef.current = terminal;
156 fitAddonRef.current = fitAddon;155 fitAddonRef.current = fitAddon;
157156
157 const api = readNativeApi();
158 if (!api) return;
159
158 const sendTerminalInput = async (data: string, fallbackError: string) => {160 const sendTerminalInput = async (data: string, fallbackError: string) => {
159 const activeTerminal = terminalRef.current;161 const activeTerminal = terminalRef.current;
160 if (!activeTerminal) return;162 if (!activeTerminal) return;
...@@ -210,14 +212,10 @@ function TerminalViewport({...@@ -210,14 +212,10 @@ function TerminalViewport({
210 end: { x: match.end, y: bufferLineNumber },212 end: { x: match.end, y: bufferLineNumber },
211 },213 },
212 activate: (event: MouseEvent) => {214 activate: (event: MouseEvent) => {
213 if (!isTerminalLinkActivation(event)) {215 if (!isTerminalLinkActivation(event)) return;
214 return;
215 }
216216
217 const latestTerminal = terminalRef.current;217 const latestTerminal = terminalRef.current;
218 if (!latestTerminal) {218 if (!latestTerminal) return;
219 return;
220 }
221219
222 if (match.kind === "url") {220 if (match.kind === "url") {
223 void api.shell.openExternal(match.text).catch((error) => {221 void api.shell.openExternal(match.text).catch((error) => {
...@@ -297,7 +295,7 @@ function TerminalViewport({...@@ -297,7 +295,7 @@ function TerminalViewport({
297 }295 }
298 };296 };
299297
300 const unsubscribe = api.terminal.onEvent((event) => {298 const unsubscribe = api?.terminal.onEvent((event) => {
301 if (event.threadId !== threadId || event.terminalId !== terminalId) return;299 if (event.threadId !== threadId || event.terminalId !== terminalId) return;
302 const activeTerminal = terminalRef.current;300 const activeTerminal = terminalRef.current;
303 if (!activeTerminal) return;301 if (!activeTerminal) return;
...@@ -372,7 +370,7 @@ function TerminalViewport({...@@ -372,7 +370,7 @@ function TerminalViewport({
372 fitAddonRef.current = null;370 fitAddonRef.current = null;
373 terminal.dispose();371 terminal.dispose();
374 };372 };
375 }, [api, cwd, runtimeEnv, terminalId, threadId]);373 }, [cwd, runtimeEnv, terminalId, threadId]);
376374
377 useEffect(() => {375 useEffect(() => {
378 if (!autoFocus) return;376 if (!autoFocus) return;
...@@ -387,9 +385,10 @@ function TerminalViewport({...@@ -387,9 +385,10 @@ function TerminalViewport({
387 }, [autoFocus, focusRequestId]);385 }, [autoFocus, focusRequestId]);
388386
389 useEffect(() => {387 useEffect(() => {
388 const api = readNativeApi();
390 const terminal = terminalRef.current;389 const terminal = terminalRef.current;
391 const fitAddon = fitAddonRef.current;390 const fitAddon = fitAddonRef.current;
392 if (!terminal || !fitAddon) return;391 if (!api || !terminal || !fitAddon) return;
393 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;392 const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
394 const frame = window.requestAnimationFrame(() => {393 const frame = window.requestAnimationFrame(() => {
395 fitAddon.fit();394 fitAddon.fit();
...@@ -408,14 +407,12 @@ function TerminalViewport({...@@ -408,14 +407,12 @@ function TerminalViewport({
408 return () => {407 return () => {
409 window.cancelAnimationFrame(frame);408 window.cancelAnimationFrame(frame);
410 };409 };
411 }, [api, drawerHeight, resizeEpoch, terminalId, threadId]);410 }, [drawerHeight, resizeEpoch, terminalId, threadId]);
412
413 return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;411 return <div ref={containerRef} className="h-full w-full overflow-hidden rounded-[4px]" />;
414}412}
415413
416interface ThreadTerminalDrawerProps {414interface ThreadTerminalDrawerProps {
417 api: NativeApi;415 threadId: ThreadId;
418 threadId: string;
419 cwd: string;416 cwd: string;
420 runtimeEnv?: Record<string, string>;417 runtimeEnv?: Record<string, string>;
421 height: number;418 height: number;
...@@ -464,7 +461,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA...@@ -464,7 +461,6 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA
464}461}
465462
466export default function ThreadTerminalDrawer({463export default function ThreadTerminalDrawer({
467 api,
468 threadId,464 threadId,
469 cwd,465 cwd,
470 runtimeEnv,466 runtimeEnv,
...@@ -783,7 +779,6 @@ export default function ThreadTerminalDrawer({...@@ -783,7 +779,6 @@ export default function ThreadTerminalDrawer({
783 >779 >
784 <div className="h-full p-1">780 <div className="h-full p-1">
785 <TerminalViewport781 <TerminalViewport
786 api={api}
787 threadId={threadId}782 threadId={threadId}
788 terminalId={terminalId}783 terminalId={terminalId}
789 cwd={cwd}784 cwd={cwd}
...@@ -801,7 +796,6 @@ export default function ThreadTerminalDrawer({...@@ -801,7 +796,6 @@ export default function ThreadTerminalDrawer({
801 <div className="h-full p-1">796 <div className="h-full p-1">
802 <TerminalViewport797 <TerminalViewport
803 key={resolvedActiveTerminalId}798 key={resolvedActiveTerminalId}
804 api={api}
805 threadId={threadId}799 threadId={threadId}
806 terminalId={resolvedActiveTerminalId}800 terminalId={resolvedActiveTerminalId}
807 cwd={cwd}801 cwd={cwd}
apps/web/src/components/ui/command.tsx+4-1
...@@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc...@@ -133,7 +133,10 @@ function CommandEmpty({ className, ...props }: React.ComponentProps<typeof Autoc
133function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {133function CommandPanel({ className, ...props }: React.ComponentProps<"div">) {
134 return (134 return (
135 <div135 <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 )}
137 {...props}140 {...props}
138 />141 />
139 );142 );
apps/web/src/components/ui/sheet.tsx+1
...@@ -49,6 +49,7 @@ function SheetViewport({...@@ -49,6 +49,7 @@ function SheetViewport({
49 side === "left" && "flex justify-start",49 side === "left" && "flex justify-start",
50 side === "right" && "flex justify-end",50 side === "right" && "flex justify-end",
51 variant === "inset" && "sm:p-4",51 variant === "inset" && "sm:p-4",
52 className,
52 )}53 )}
53 data-slot="sheet-viewport"54 data-slot="sheet-viewport"
54 {...props}55 {...props}
apps/web/src/components/ui/toast.tsx+6-4
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";4import { useEffect } from "react";
5import { useParams } from "@tanstack/react-router";5import { useParams } from "@tanstack/react-router";
6import { ThreadId } from "@t3tools/contracts";
6import {7import {
7 CircleAlertIcon,8 CircleAlertIcon,
8 CircleCheckIcon,9 CircleCheckIcon,
...@@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button";...@@ -16,7 +17,7 @@ import { buttonVariants } from "~/components/ui/button";
16import { shouldHideCollapsedToastContent } from "./toast.logic";17import { shouldHideCollapsedToastContent } from "./toast.logic";
1718
18type ThreadToastData = {19type ThreadToastData = {
19 threadId?: string | null;20 threadId?: ThreadId | null;
20 tooltipStyle?: boolean;21 tooltipStyle?: boolean;
21 dismissAfterVisibleMs?: number;22 dismissAfterVisibleMs?: number;
22};23};
...@@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props {...@@ -48,17 +49,18 @@ interface ToastProviderProps extends Toast.Provider.Props {
4849
49function shouldRenderForActiveThread(50function shouldRenderForActiveThread(
50 data: ThreadToastData | undefined,51 data: ThreadToastData | undefined,
51 activeThreadId: string | null,52 activeThreadId: ThreadId | null,
52): boolean {53): boolean {
53 const toastThreadId = data?.threadId;54 const toastThreadId = data?.threadId;
54 if (!toastThreadId) return true;55 if (!toastThreadId) return true;
55 return toastThreadId === activeThreadId;56 return toastThreadId === activeThreadId;
56}57}
5758
58function useActiveThreadIdFromRoute(): string | null {59function useActiveThreadIdFromRoute(): ThreadId | null {
59 return useParams({60 return useParams({
60 strict: false,61 strict: false,
61 select: (params) => params.threadId ?? null,62 select: (params) =>
63 typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null,
62 });64 });
63}65}
6466
apps/web/src/diffRouteSearch.ts+5-2
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { TurnId } from "@t3tools/contracts";
2
1export interface DiffRouteSearch {3export interface DiffRouteSearch {
2 diff?: "1";4 diff?: "1";
3 diffTurnId?: string;5 diffTurnId?: TurnId;
4 diffFilePath?: string;6 diffFilePath?: string;
5}7}
68
...@@ -25,7 +27,8 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>(...@@ -25,7 +27,8 @@ export function stripDiffSearchParams<T extends Record<string, unknown>>(
2527
26export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {28export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {
27 const diff = isDiffOpenValue(search.diff) ? "1" : undefined;29 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;
29 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;32 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
3033
31 return {34 return {
apps/web/src/historyBootstrap.test.ts+10-7
...@@ -1,20 +1,23 @@...@@ -1,20 +1,23 @@
1import { MessageId } from "@t3tools/contracts";
1import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
23
3import { buildBootstrapInput } from "./historyBootstrap";4import { buildBootstrapInput } from "./historyBootstrap";
45
6const messageId = (value: string) => MessageId.makeUnsafe(value);
7
5describe("buildBootstrapInput", () => {8describe("buildBootstrapInput", () => {
6 it("includes full transcript when under budget", () => {9 it("includes full transcript when under budget", () => {
7 const result = buildBootstrapInput(10 const result = buildBootstrapInput(
8 [11 [
9 {12 {
10 id: "u-1",13 id: messageId("u-1"),
11 role: "user",14 role: "user",
12 text: "hello",15 text: "hello",
13 createdAt: "2026-02-09T00:00:00.000Z",16 createdAt: "2026-02-09T00:00:00.000Z",
14 streaming: false,17 streaming: false,
15 },18 },
16 {19 {
17 id: "a-1",20 id: messageId("a-1"),
18 role: "assistant",21 role: "assistant",
19 text: "world",22 text: "world",
20 createdAt: "2026-02-09T00:00:01.000Z",23 createdAt: "2026-02-09T00:00:01.000Z",
...@@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => {...@@ -38,21 +41,21 @@ describe("buildBootstrapInput", () => {
38 const result = buildBootstrapInput(41 const result = buildBootstrapInput(
39 [42 [
40 {43 {
41 id: "u-1",44 id: messageId("u-1"),
42 role: "user",45 role: "user",
43 text: "first question with details",46 text: "first question with details",
44 createdAt: "2026-02-09T00:00:00.000Z",47 createdAt: "2026-02-09T00:00:00.000Z",
45 streaming: false,48 streaming: false,
46 },49 },
47 {50 {
48 id: "a-1",51 id: messageId("a-1"),
49 role: "assistant",52 role: "assistant",
50 text: "first answer with details",53 text: "first answer with details",
51 createdAt: "2026-02-09T00:00:01.000Z",54 createdAt: "2026-02-09T00:00:01.000Z",
52 streaming: false,55 streaming: false,
53 },56 },
54 {57 {
55 id: "u-2",58 id: messageId("u-2"),
56 role: "user",59 role: "user",
57 text: "second question with details",60 text: "second question with details",
58 createdAt: "2026-02-09T00:00:02.000Z",61 createdAt: "2026-02-09T00:00:02.000Z",
...@@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => {...@@ -75,7 +78,7 @@ describe("buildBootstrapInput", () => {
75 const result = buildBootstrapInput(78 const result = buildBootstrapInput(
76 [79 [
77 {80 {
78 id: "u-1",81 id: messageId("u-1"),
79 role: "user",82 role: "user",
80 text: "old context",83 text: "old context",
81 createdAt: "2026-02-09T00:00:00.000Z",84 createdAt: "2026-02-09T00:00:00.000Z",
...@@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => {...@@ -96,7 +99,7 @@ describe("buildBootstrapInput", () => {
96 const result = buildBootstrapInput(99 const result = buildBootstrapInput(
97 [100 [
98 {101 {
99 id: "u-image",102 id: messageId("u-image"),
100 role: "user",103 role: "user",
101 text: "",104 text: "",
102 attachments: [105 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 @@...@@ -1,5 +1,6 @@
1import type { GitStackedAction, NativeApi } from "@t3tools/contracts";1import type { GitStackedAction } from "@t3tools/contracts";
2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";2import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";
34
4const GIT_STATUS_STALE_TIME_MS = 5_000;5const GIT_STATUS_STALE_TIME_MS = 5_000;
5const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;6const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
...@@ -16,16 +17,15 @@ export function invalidateGitQueries(queryClient: QueryClient) {...@@ -16,16 +17,15 @@ export function invalidateGitQueries(queryClient: QueryClient) {
16 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });17 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
17}18}
1819
19export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string | null) {20export function gitStatusQueryOptions(cwd: string | null) {
20 return queryOptions({21 return queryOptions({
21 queryKey: gitQueryKeys.status(cwd),22 queryKey: gitQueryKeys.status(cwd),
22 queryFn: async () => {23 queryFn: async () => {
23 if (!api || !cwd) {24 const api = ensureNativeApi();
24 throw new Error("Git status is unavailable.");25 if (!cwd) throw new Error("Git status is unavailable.");
25 }
26 return api.git.status({ cwd });26 return api.git.status({ cwd });
27 },27 },
28 enabled: !!api && !!cwd,28 enabled: cwd !== null,
29 staleTime: GIT_STATUS_STALE_TIME_MS,29 staleTime: GIT_STATUS_STALE_TIME_MS,
30 refetchOnWindowFocus: "always",30 refetchOnWindowFocus: "always",
31 refetchOnReconnect: "always",31 refetchOnReconnect: "always",
...@@ -33,16 +33,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |...@@ -33,16 +33,15 @@ export function gitStatusQueryOptions(api: NativeApi | undefined, cwd: string |
33 });33 });
34}34}
3535
36export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string | null) {36export function gitBranchesQueryOptions(cwd: string | null) {
37 return queryOptions({37 return queryOptions({
38 queryKey: gitQueryKeys.branches(cwd),38 queryKey: gitQueryKeys.branches(cwd),
39 queryFn: async () => {39 queryFn: async () => {
40 if (!api || !cwd) {40 const api = ensureNativeApi();
41 throw new Error("Git branches are unavailable.");41 if (!cwd) throw new Error("Git branches are unavailable.");
42 }
43 return api.git.listBranches({ cwd });42 return api.git.listBranches({ cwd });
44 },43 },
45 enabled: !!api && !!cwd,44 enabled: cwd !== null,
46 staleTime: GIT_BRANCHES_STALE_TIME_MS,45 staleTime: GIT_BRANCHES_STALE_TIME_MS,
47 refetchOnWindowFocus: true,46 refetchOnWindowFocus: true,
48 refetchOnReconnect: true,47 refetchOnReconnect: true,
...@@ -50,15 +49,12 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string...@@ -50,15 +49,12 @@ export function gitBranchesQueryOptions(api: NativeApi | undefined, cwd: string
50 });49 });
51}50}
5251
53export function gitInitMutationOptions(input: {52export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
54 api: NativeApi | undefined;
55 cwd: string | null;
56 queryClient: QueryClient;
57}) {
58 return mutationOptions({53 return mutationOptions({
59 mutationFn: async () => {54 mutationFn: async () => {
60 if (!input.api || !input.cwd) throw new Error("Git init is unavailable.");55 const api = ensureNativeApi();
61 return input.api.git.init({ cwd: input.cwd });56 if (!input.cwd) throw new Error("Git init is unavailable.");
57 return api.git.init({ cwd: input.cwd });
62 },58 },
63 onSuccess: async () => {59 onSuccess: async () => {
64 await invalidateGitQueries(input.queryClient);60 await invalidateGitQueries(input.queryClient);
...@@ -67,14 +63,14 @@ export function gitInitMutationOptions(input: {...@@ -67,14 +63,14 @@ export function gitInitMutationOptions(input: {
67}63}
6864
69export function gitCheckoutMutationOptions(input: {65export function gitCheckoutMutationOptions(input: {
70 api: NativeApi | undefined;
71 cwd: string | null;66 cwd: string | null;
72 queryClient: QueryClient;67 queryClient: QueryClient;
73}) {68}) {
74 return mutationOptions({69 return mutationOptions({
75 mutationFn: async (branch: string) => {70 mutationFn: async (branch: string) => {
76 if (!input.api || !input.cwd) throw new Error("Git checkout is unavailable.");71 const api = ensureNativeApi();
77 return input.api.git.checkout({ cwd: input.cwd, branch });72 if (!input.cwd) throw new Error("Git checkout is unavailable.");
73 return api.git.checkout({ cwd: input.cwd, branch });
78 },74 },
79 onSuccess: async () => {75 onSuccess: async () => {
80 await invalidateGitQueries(input.queryClient);76 await invalidateGitQueries(input.queryClient);
...@@ -83,15 +79,15 @@ export function gitCheckoutMutationOptions(input: {...@@ -83,15 +79,15 @@ export function gitCheckoutMutationOptions(input: {
83}79}
8480
85export function gitCreateBranchAndCheckoutMutationOptions(input: {81export function gitCreateBranchAndCheckoutMutationOptions(input: {
86 api: NativeApi | undefined;
87 cwd: string | null;82 cwd: string | null;
88 queryClient: QueryClient;83 queryClient: QueryClient;
89}) {84}) {
90 return mutationOptions({85 return mutationOptions({
91 mutationFn: async (branch: string) => {86 mutationFn: async (branch: string) => {
92 if (!input.api || !input.cwd) throw new Error("Git branch creation is unavailable.");87 const api = ensureNativeApi();
93 await input.api.git.createBranch({ cwd: input.cwd, branch });88 if (!input.cwd) throw new Error("Git branch creation is unavailable.");
94 return input.api.git.checkout({ cwd: input.cwd, branch });89 await api.git.createBranch({ cwd: input.cwd, branch });
90 return api.git.checkout({ cwd: input.cwd, branch });
95 },91 },
96 onSuccess: async () => {92 onSuccess: async () => {
97 await invalidateGitQueries(input.queryClient);93 await invalidateGitQueries(input.queryClient);
...@@ -100,7 +96,6 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: {...@@ -100,7 +96,6 @@ export function gitCreateBranchAndCheckoutMutationOptions(input: {
100}96}
10197
102export function gitRunStackedActionMutationOptions(input: {98export function gitRunStackedActionMutationOptions(input: {
103 api: NativeApi | undefined;
104 cwd: string | null;99 cwd: string | null;
105 queryClient: QueryClient;100 queryClient: QueryClient;
106}) {101}) {
...@@ -112,10 +107,9 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -112,10 +107,9 @@ export function gitRunStackedActionMutationOptions(input: {
112 action: GitStackedAction;107 action: GitStackedAction;
113 commitMessage?: string;108 commitMessage?: string;
114 }) => {109 }) => {
115 if (!input.api || !input.cwd) {110 const api = ensureNativeApi();
116 throw new Error("Git action is unavailable.");111 if (!input.cwd) throw new Error("Git action is unavailable.");
117 }112 return api.git.runStackedAction({
118 return input.api.git.runStackedAction({
119 cwd: input.cwd,113 cwd: input.cwd,
120 action,114 action,
121 ...(commitMessage ? { commitMessage } : {}),115 ...(commitMessage ? { commitMessage } : {}),
...@@ -127,15 +121,12 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -127,15 +121,12 @@ export function gitRunStackedActionMutationOptions(input: {
127 });121 });
128}122}
129123
130export function gitPullMutationOptions(input: {124export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) {
131 api: NativeApi | undefined;
132 cwd: string | null;
133 queryClient: QueryClient;
134}) {
135 return mutationOptions({125 return mutationOptions({
136 mutationFn: async () => {126 mutationFn: async () => {
137 if (!input.api || !input.cwd) throw new Error("Git pull is unavailable.");127 const api = ensureNativeApi();
138 return input.api.git.pull({ cwd: input.cwd });128 if (!input.cwd) throw new Error("Git pull is unavailable.");
129 return api.git.pull({ cwd: input.cwd });
139 },130 },
140 onSettled: async () => {131 onSettled: async () => {
141 await invalidateGitQueries(input.queryClient);132 await invalidateGitQueries(input.queryClient);
...@@ -143,10 +134,7 @@ export function gitPullMutationOptions(input: {...@@ -143,10 +134,7 @@ export function gitPullMutationOptions(input: {
143 });134 });
144}135}
145136
146export function gitCreateWorktreeMutationOptions(input: {137export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
147 api: NativeApi | undefined;
148 queryClient: QueryClient;
149}) {
150 return mutationOptions({138 return mutationOptions({
151 mutationFn: async ({139 mutationFn: async ({
152 cwd,140 cwd,
...@@ -159,10 +147,9 @@ export function gitCreateWorktreeMutationOptions(input: {...@@ -159,10 +147,9 @@ export function gitCreateWorktreeMutationOptions(input: {
159 newBranch: string;147 newBranch: string;
160 path?: string | null;148 path?: string | null;
161 }) => {149 }) => {
162 if (!input.api) {150 const api = ensureNativeApi();
163 throw new Error("Git worktree creation is unavailable.");151 if (!cwd) throw new Error("Git worktree creation is unavailable.");
164 }152 return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
165 return input.api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
166 },153 },
167 onSettled: async () => {154 onSettled: async () => {
168 await invalidateGitQueries(input.queryClient);155 await invalidateGitQueries(input.queryClient);
...@@ -170,16 +157,12 @@ export function gitCreateWorktreeMutationOptions(input: {...@@ -170,16 +157,12 @@ export function gitCreateWorktreeMutationOptions(input: {
170 });157 });
171}158}
172159
173export function gitRemoveWorktreeMutationOptions(input: {160export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
174 api: NativeApi | undefined;
175 queryClient: QueryClient;
176}) {
177 return mutationOptions({161 return mutationOptions({
178 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {162 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
179 if (!input.api) {163 const api = ensureNativeApi();
180 throw new Error("Git worktree removal is unavailable.");164 if (!cwd) throw new Error("Git worktree removal is unavailable.");
181 }165 return api.git.removeWorktree({ cwd, path, force });
182 return input.api.git.removeWorktree({ cwd, path, force });
183 },166 },
184 onSettled: async () => {167 onSettled: async () => {
185 await invalidateGitQueries(input.queryClient);168 await invalidateGitQueries(input.queryClient);
apps/web/src/lib/orchestrationIds.ts-1
...@@ -8,7 +8,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID...@@ -8,7 +8,6 @@ export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID
88
9export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID());9export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID());
1010
11export const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
1211
13export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);12export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
1413
apps/web/src/lib/projectReactQuery.ts+6-5
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { NativeApi, ProjectSearchEntriesResult } from "@t3tools/contracts";1import type { ProjectSearchEntriesResult } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";2import { queryOptions } from "@tanstack/react-query";
3import { ensureNativeApi } from "~/nativeApi";
34
4export const projectQueryKeys = {5export const projectQueryKeys = {
5 all: ["projects"] as const,6 all: ["projects"] as const,
...@@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {...@@ -15,7 +16,6 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
15};16};
1617
17export function projectSearchEntriesQueryOptions(input: {18export function projectSearchEntriesQueryOptions(input: {
18 api: NativeApi | undefined;
19 cwd: string | null;19 cwd: string | null;
20 query: string;20 query: string;
21 enabled?: boolean;21 enabled?: boolean;
...@@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: {...@@ -26,16 +26,17 @@ export function projectSearchEntriesQueryOptions(input: {
26 return queryOptions({26 return queryOptions({
27 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),27 queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit),
28 queryFn: async () => {28 queryFn: async () => {
29 if (!input.api || !input.cwd) {29 const api = ensureNativeApi();
30 if (!input.cwd) {
30 throw new Error("Workspace entry search is unavailable.");31 throw new Error("Workspace entry search is unavailable.");
31 }32 }
32 return input.api.projects.searchEntries({33 return api.projects.searchEntries({
33 cwd: input.cwd,34 cwd: input.cwd,
34 query: input.query,35 query: input.query,
35 limit,36 limit,
36 });37 });
37 },38 },
38 enabled: (input.enabled ?? true) && Boolean(input.api && input.cwd),39 enabled: (input.enabled ?? true) && input.cwd !== null,
39 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,40 staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
40 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,41 placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
41 });42 });
apps/web/src/lib/providerReactQuery.test.ts+57-27
...@@ -1,12 +1,31 @@...@@ -1,12 +1,31 @@
1import type { NativeApi } from "@t3tools/contracts";1import { ThreadId, type NativeApi } from "@t3tools/contracts";
2import { QueryClient } from "@tanstack/react-query";2import { QueryClient } from "@tanstack/react-query";
3import { describe, expect, it, vi } from "vitest";3import { afterEach, describe, expect, it, vi } from "vitest";
4import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery";4import { 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
6describe("providerQueryKeys.checkpointDiff", () => {25describe("providerQueryKeys.checkpointDiff", () => {
7 it("includes cacheScope so reused turn counts do not collide", () => {26 it("includes cacheScope so reused turn counts do not collide", () => {
8 const baseInput = {27 const baseInput = {
9 threadId: "thread-id",28 threadId,
10 fromTurnCount: 1,29 fromTurnCount: 1,
11 toTurnCount: 2,30 toTurnCount: 2,
12 } as const;31 } as const;
...@@ -29,15 +48,10 @@ describe("checkpointDiffQueryOptions", () => {...@@ -29,15 +48,10 @@ describe("checkpointDiffQueryOptions", () => {
29 it("forwards checkpoint range to the provider API", async () => {48 it("forwards checkpoint range to the provider API", async () => {
30 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });49 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
31 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });50 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
32 const api = {51 mockNativeApi({ getTurnDiff, getFullThreadDiff });
33 orchestration: {52
34 getTurnDiff,53 const options = checkpointDiffQueryOptions({
35 getFullThreadDiff,54 threadId,
36 },
37 } as unknown as NativeApi;
38
39 const options = checkpointDiffQueryOptions(api, {
40 threadId: "thread-id",
41 fromTurnCount: 3,55 fromTurnCount: 3,
42 toTurnCount: 4,56 toTurnCount: 4,
43 cacheScope: "turn:abc",57 cacheScope: "turn:abc",
...@@ -47,7 +61,7 @@ describe("checkpointDiffQueryOptions", () => {...@@ -47,7 +61,7 @@ describe("checkpointDiffQueryOptions", () => {
47 await queryClient.fetchQuery(options);61 await queryClient.fetchQuery(options);
4862
49 expect(getTurnDiff).toHaveBeenCalledWith({63 expect(getTurnDiff).toHaveBeenCalledWith({
50 threadId: "thread-id",64 threadId,
51 fromTurnCount: 3,65 fromTurnCount: 3,
52 toTurnCount: 4,66 toTurnCount: 4,
53 });67 });
...@@ -57,15 +71,10 @@ describe("checkpointDiffQueryOptions", () => {...@@ -57,15 +71,10 @@ describe("checkpointDiffQueryOptions", () => {
57 it("uses explicit full thread diff API when range starts from zero", async () => {71 it("uses explicit full thread diff API when range starts from zero", async () => {
58 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });72 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
59 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });73 const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
60 const api = {74 mockNativeApi({ getTurnDiff, getFullThreadDiff });
61 orchestration: {75
62 getTurnDiff,76 const options = checkpointDiffQueryOptions({
63 getFullThreadDiff,77 threadId,
64 },
65 } as unknown as NativeApi;
66
67 const options = checkpointDiffQueryOptions(api, {
68 threadId: "thread-id",
69 fromTurnCount: 0,78 fromTurnCount: 0,
70 toTurnCount: 2,79 toTurnCount: 2,
71 cacheScope: "thread:all",80 cacheScope: "thread:all",
...@@ -75,15 +84,36 @@ describe("checkpointDiffQueryOptions", () => {...@@ -75,15 +84,36 @@ describe("checkpointDiffQueryOptions", () => {
75 await queryClient.fetchQuery(options);84 await queryClient.fetchQuery(options);
7685
77 expect(getFullThreadDiff).toHaveBeenCalledWith({86 expect(getFullThreadDiff).toHaveBeenCalledWith({
78 threadId: "thread-id",87 threadId,
79 toTurnCount: 2,88 toTurnCount: 2,
80 });89 });
81 expect(getTurnDiff).not.toHaveBeenCalled();90 expect(getTurnDiff).not.toHaveBeenCalled();
82 });91 });
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
84 it("retries checkpoint-not-ready errors longer than generic failures", () => {114 it("retries checkpoint-not-ready errors longer than generic failures", () => {
85 const options = checkpointDiffQueryOptions(undefined, {115 const options = checkpointDiffQueryOptions({
86 threadId: "thread-id",116 threadId,
87 fromTurnCount: 1,117 fromTurnCount: 1,
88 toTurnCount: 2,118 toTurnCount: 2,
89 cacheScope: "turn:abc",119 cacheScope: "turn:abc",
...@@ -106,8 +136,8 @@ describe("checkpointDiffQueryOptions", () => {...@@ -106,8 +136,8 @@ describe("checkpointDiffQueryOptions", () => {
106 });136 });
107137
108 it("backs off longer for checkpoint-not-ready errors", () => {138 it("backs off longer for checkpoint-not-ready errors", () => {
109 const options = checkpointDiffQueryOptions(undefined, {139 const options = checkpointDiffQueryOptions({
110 threadId: "thread-id",140 threadId,
111 fromTurnCount: 1,141 fromTurnCount: 1,
112 toTurnCount: 2,142 toTurnCount: 2,
113 cacheScope: "turn:abc",143 cacheScope: "turn:abc",
apps/web/src/lib/providerReactQuery.ts+32-32
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1import type { NativeApi } from "@t3tools/contracts";1import {
2 OrchestrationGetFullThreadDiffInput,
3 OrchestrationGetTurnDiffInput,
4 ThreadId,
5} from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";6import { queryOptions } from "@tanstack/react-query";
3import { asThreadId } from "./orchestrationIds";7import { Option, Schema } from "effect";
8import { ensureNativeApi } from "../nativeApi";
49
5export interface CheckpointDiffQueryInput {10interface CheckpointDiffQueryInput {
6 threadId: string | null;11 threadId: ThreadId | null;
7 fromTurnCount: number | null;12 fromTurnCount: number | null;
8 toTurnCount: number | null;13 toTurnCount: number | null;
9 cacheScope?: string | null;14 cacheScope?: string | null;
...@@ -22,6 +27,21 @@ export const providerQueryKeys = {...@@ -22,6 +27,21 @@ export const providerQueryKeys = {
22 ] as const,27 ] as const,
23};28};
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
25function asCheckpointErrorMessage(error: unknown): string {45function asCheckpointErrorMessage(error: unknown): string {
26 if (error instanceof Error) {46 if (error instanceof Error) {
27 return error.message;47 return error.message;
...@@ -41,42 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean {...@@ -41,42 +61,22 @@ function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
41 );61 );
42}62}
4363
44export function checkpointDiffQueryOptions(64export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
45 api: NativeApi | undefined,65 const decodedRequest = decodeCheckpointDiffRequest(input);
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;
5666
57 return queryOptions({67 return queryOptions({
58 queryKey: providerQueryKeys.checkpointDiff(input),68 queryKey: providerQueryKeys.checkpointDiff(input),
59 queryFn: async () => {69 queryFn: async () => {
60 if (!api || !input.threadId || !hasValidRange) {70 const api = ensureNativeApi();
71 if (!input.threadId || decodedRequest._tag === "None") {
61 throw new Error("Checkpoint diff is unavailable.");72 throw new Error("Checkpoint diff is unavailable.");
62 }73 }
63 const { fromTurnCount, toTurnCount } = input;74 if (decodedRequest.value.kind === "fullThreadDiff") {
64 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {75 return api.orchestration.getFullThreadDiff(decodedRequest.value.input);
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 });
72 }76 }
73 return api.orchestration.getTurnDiff({77 return api.orchestration.getTurnDiff(decodedRequest.value.input);
74 threadId: asThreadId(input.threadId),
75 fromTurnCount,
76 toTurnCount,
77 });
78 },78 },
79 enabled: !!api && !!input.threadId && hasValidRange,79 enabled: !!input.threadId && decodedRequest._tag === "Some",
80 staleTime: Infinity,80 staleTime: Infinity,
81 retry: (failureCount, error) => {81 retry: (failureCount, error) => {
82 if (isCheckpointTemporarilyUnavailable(error)) {82 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/lib/serverReactQuery.ts+3-6
...@@ -1,21 +1,18 @@...@@ -1,21 +1,18 @@
1import type { NativeApi } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";1import { queryOptions } from "@tanstack/react-query";
2import { ensureNativeApi } from "~/nativeApi";
33
4export const serverQueryKeys = {4export const serverQueryKeys = {
5 all: ["server"] as const,5 all: ["server"] as const,
6 config: () => ["server", "config"] as const,6 config: () => ["server", "config"] as const,
7};7};
88
9export function serverConfigQueryOptions(api: NativeApi | undefined) {9export function serverConfigQueryOptions() {
10 return queryOptions({10 return queryOptions({
11 queryKey: serverQueryKeys.config(),11 queryKey: serverQueryKeys.config(),
12 queryFn: async () => {12 queryFn: async () => {
13 if (!api || !api.server || typeof api.server.getConfig !== "function") {13 const api = ensureNativeApi();
14 throw new Error("Server config is unavailable.");
15 }
16 return api.server.getConfig();14 return api.server.getConfig();
17 },15 },
18 enabled: !!api && !!api.server && typeof api.server.getConfig === "function",
19 staleTime: Infinity,16 staleTime: Infinity,
20 });17 });
21}18}
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 {...@@ -16,3 +16,11 @@ export function readNativeApi(): NativeApi | undefined {
16 cachedApi = createWsNativeApi();16 cachedApi = createWsNativeApi();
17 return cachedApi;17 return cachedApi;
18}18}
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";...@@ -9,7 +9,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
9import { APP_DISPLAY_NAME } from "../branding";9import { APP_DISPLAY_NAME } from "../branding";
10import { Button } from "../components/ui/button";10import { Button } from "../components/ui/button";
11import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";11import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
12import { useNativeApi } from "../hooks/useNativeApi";12import { readNativeApi } from "../nativeApi";
13import { useStore } from "../store";13import { useStore } from "../store";
14import { onServerWelcome } from "../wsNativeApi";14import { onServerWelcome } from "../wsNativeApi";
15import { providerQueryKeys } from "../lib/providerReactQuery";15import { providerQueryKeys } from "../lib/providerReactQuery";
...@@ -22,9 +22,7 @@ export const Route = createRootRouteWithContext<{...@@ -22,9 +22,7 @@ export const Route = createRootRouteWithContext<{
22});22});
2323
24function RootRouteView() {24function RootRouteView() {
25 const api = useNativeApi();25 if (!readNativeApi()) {
26
27 if (!api) {
28 return (26 return (
29 <div className="flex h-screen flex-col bg-background text-foreground">27 <div className="flex h-screen flex-col bg-background text-foreground">
30 <div className="flex flex-1 items-center justify-center">28 <div className="flex flex-1 items-center justify-center">
...@@ -119,11 +117,11 @@ function errorDetails(error: unknown): string {...@@ -119,11 +117,11 @@ function errorDetails(error: unknown): string {
119}117}
120118
121function EventRouter() {119function EventRouter() {
122 const api = useNativeApi();
123 const { dispatch } = useStore();120 const { dispatch } = useStore();
124 const queryClient = useQueryClient();121 const queryClient = useQueryClient();
125122
126 useEffect(() => {123 useEffect(() => {
124 const api = readNativeApi();
127 if (!api) return;125 if (!api) return;
128 let disposed = false;126 let disposed = false;
129 let latestSequence = 0;127 let latestSequence = 0;
...@@ -177,7 +175,7 @@ function EventRouter() {...@@ -177,7 +175,7 @@ function EventRouter() {
177 unsubDomainEvent();175 unsubDomainEvent();
178 unsubWelcome();176 unsubWelcome();
179 };177 };
180 }, [api, dispatch, queryClient]);178 }, [dispatch, queryClient]);
181179
182 return null;180 return null;
183}181}
apps/web/src/routes/_chat.$threadId.tsx+6-8
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ThreadId } from "@t3tools/contracts";
1import { createFileRoute, useNavigate } from "@tanstack/react-router";2import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";3import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
34
...@@ -133,9 +134,11 @@ const DiffPanelInlineSidebar = (props: {...@@ -133,9 +134,11 @@ const DiffPanelInlineSidebar = (props: {
133function ChatThreadRouteView() {134function ChatThreadRouteView() {
134 const { state } = useStore();135 const { state } = useStore();
135 const navigate = useNavigate();136 const navigate = useNavigate();
136 const { threadId } = Route.useParams();137 const threadId = Route.useParams({
138 select: (params) => ThreadId.makeUnsafe(params.threadId),
139 });
137 const search = Route.useSearch();140 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);
139 const diffOpen = search.diff === "1";142 const diffOpen = search.diff === "1";
140 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);143 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
141 const closeDiff = useCallback(() => {144 const closeDiff = useCallback(() => {
...@@ -159,11 +162,6 @@ function ChatThreadRouteView() {...@@ -159,11 +162,6 @@ function ChatThreadRouteView() {
159 }, [navigate, threadId]);162 }, [navigate, threadId]);
160163
161 useEffect(() => {164 useEffect(() => {
162 if (!threadId) {
163 void navigate({ to: "/", replace: true });
164 return;
165 }
166
167 if (!state.threadsHydrated) {165 if (!state.threadsHydrated) {
168 return;166 return;
169 }167 }
...@@ -174,7 +172,7 @@ function ChatThreadRouteView() {...@@ -174,7 +172,7 @@ function ChatThreadRouteView() {
174 }172 }
175 }, [navigate, state.threadsHydrated, threadExists, threadId]);173 }, [navigate, state.threadsHydrated, threadExists, threadId]);
176174
177 if (!threadId || !state.threadsHydrated || !threadExists) {175 if (!state.threadsHydrated || !threadExists) {
178 return null;176 return null;
179 }177 }
180178
apps/web/src/routes/_chat.tsx+1-1
...@@ -3,7 +3,7 @@ import { useEffect } from "react";...@@ -3,7 +3,7 @@ import { useEffect } from "react";
33
4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
5import ThreadSidebar from "../components/Sidebar";5import ThreadSidebar from "../components/Sidebar";
6import { SidebarInset, Sidebar, SidebarProvider } from "~/components/ui/sidebar";6import { Sidebar, SidebarProvider } from "~/components/ui/sidebar";
77
8function ChatRouteLayout() {8function ChatRouteLayout() {
9 const navigate = useNavigate();9 const navigate = useNavigate();
apps/web/src/session-logic.test.ts+2-34
...@@ -1,11 +1,7 @@...@@ -1,11 +1,7 @@
1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic";
5 derivePendingApprovals,
6 deriveTurnDiffFilesFromUnifiedDiff,
7 deriveWorkLogEntries,
8} from "./session-logic";
95
10function makeActivity(overrides: {6function makeActivity(overrides: {
11 id?: string;7 id?: string;
...@@ -105,7 +101,7 @@ describe("deriveWorkLogEntries", () => {...@@ -105,7 +101,7 @@ describe("deriveWorkLogEntries", () => {
105 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),101 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),
106 ];102 ];
107103
108 const entries = deriveWorkLogEntries(activities, "turn-2");104 const entries = deriveWorkLogEntries(activities, TurnId.makeUnsafe("turn-2"));
109 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);105 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);
110 });106 });
111107
...@@ -130,31 +126,3 @@ describe("deriveWorkLogEntries", () => {...@@ -130,31 +126,3 @@ describe("deriveWorkLogEntries", () => {
130 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);126 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
131 });127 });
132});128});
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 @@...@@ -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 {8import type { ChatMessage, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";
4 ChatMessage,
5 SessionPhase,
6 ThreadSession,
7 TurnDiffFileChange,
8 TurnDiffSummary,
9} from "./types";
109
11export const PROVIDER_OPTIONS: Array<{10export const PROVIDER_OPTIONS: Array<{
12 value: ProviderKind;11 value: ProviderKind;
...@@ -26,7 +25,7 @@ export interface WorkLogEntry {...@@ -26,7 +25,7 @@ export interface WorkLogEntry {
26}25}
2726
28export interface PendingApproval {27export interface PendingApproval {
29 requestId: string;28 requestId: ApprovalRequestId;
30 requestKind: "command" | "file-change";29 requestKind: "command" | "file-change";
31 createdAt: string;30 createdAt: string;
32 detail?: string;31 detail?: string;
...@@ -79,7 +78,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str...@@ -79,7 +78,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
79export function derivePendingApprovals(78export function derivePendingApprovals(
80 activities: ReadonlyArray<OrchestrationThreadActivity>,79 activities: ReadonlyArray<OrchestrationThreadActivity>,
81): PendingApproval[] {80): PendingApproval[] {
82 const openByRequestId = new Map<string, PendingApproval>();81 const openByRequestId = new Map<ApprovalRequestId, PendingApproval>();
83 const ordered = [...activities].toSorted((left, right) =>82 const ordered = [...activities].toSorted((left, right) =>
84 left.createdAt.localeCompare(right.createdAt),83 left.createdAt.localeCompare(right.createdAt),
85 );84 );
...@@ -89,7 +88,10 @@ export function derivePendingApprovals(...@@ -89,7 +88,10 @@ export function derivePendingApprovals(
89 activity.payload && typeof activity.payload === "object"88 activity.payload && typeof activity.payload === "object"
90 ? (activity.payload as Record<string, unknown>)89 ? (activity.payload as Record<string, unknown>)
91 : null;90 : 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;
93 const requestKind =95 const requestKind =
94 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")96 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")
95 ? payload.requestKind97 ? payload.requestKind
...@@ -118,7 +120,7 @@ export function derivePendingApprovals(...@@ -118,7 +120,7 @@ export function derivePendingApprovals(
118120
119export function deriveWorkLogEntries(121export function deriveWorkLogEntries(
120 activities: ReadonlyArray<OrchestrationThreadActivity>,122 activities: ReadonlyArray<OrchestrationThreadActivity>,
121 latestTurnId: string | undefined,123 latestTurnId: TurnId | undefined,
122): WorkLogEntry[] {124): WorkLogEntry[] {
123 const ordered = [...activities].toSorted((left, right) =>125 const ordered = [...activities].toSorted((left, right) =>
124 left.createdAt.localeCompare(right.createdAt),126 left.createdAt.localeCompare(right.createdAt),
...@@ -163,55 +165,11 @@ export function deriveTimelineEntries(...@@ -163,55 +165,11 @@ export function deriveTimelineEntries(
163 return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));165 return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
164}166}
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
210export function inferCheckpointTurnCountByTurnId(168export function inferCheckpointTurnCountByTurnId(
211 summaries: TurnDiffSummary[],169 summaries: TurnDiffSummary[],
212): Record<string, number> {170): Record<TurnId, number> {
213 const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt));171 const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt));
214 const result: Record<string, number> = {};172 const result: Record<TurnId, number> = {};
215 for (let index = 0; index < sorted.length; index += 1) {173 for (let index = 0; index < sorted.length; index += 1) {
216 const summary = sorted[index];174 const summary = sorted[index];
217 if (!summary) continue;175 if (!summary) continue;
...@@ -220,12 +178,6 @@ export function inferCheckpointTurnCountByTurnId(...@@ -220,12 +178,6 @@ export function inferCheckpointTurnCountByTurnId(
220 return result;178 return result;
221}179}
222180
223export function deriveTurnDiffSummaries(
224 _events: ReadonlyArray<OrchestrationThreadActivity>,
225): TurnDiffSummary[] {
226 return [];
227}
228
229export function derivePhase(session: ThreadSession | null): SessionPhase {181export function derivePhase(session: ThreadSession | null): SessionPhase {
230 if (!session || session.status === "closed") return "disconnected";182 if (!session || session.status === "closed") return "disconnected";
231 if (session.status === "connecting") return "connecting";183 if (session.status === "connecting") return "connecting";
apps/web/src/store.ts+23-16
...@@ -8,15 +8,20 @@ import {...@@ -8,15 +8,20 @@ import {
8 useReducer,8 useReducer,
9} from "react";9} from "react";
1010
11import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts";11import {
12import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";12 DEFAULT_MODEL,
13 ProviderSessionId,
14 ThreadId,
15 type OrchestrationReadModel,
16 type OrchestrationSessionStatus,
17 resolveModelSlug,
18} from "@t3tools/contracts";
13import {19import {
14 DEFAULT_THREAD_TERMINAL_HEIGHT,20 DEFAULT_THREAD_TERMINAL_HEIGHT,
15 DEFAULT_THREAD_TERMINAL_ID,21 DEFAULT_THREAD_TERMINAL_ID,
16 DEFAULT_RUNTIME_MODE,22 DEFAULT_RUNTIME_MODE,
17 MAX_THREAD_TERMINAL_COUNT,23 MAX_THREAD_TERMINAL_COUNT,
18 type ChatMessage,24 type ChatMessage,
19 type ProjectScript,
20 type Project,25 type Project,
21 type RuntimeMode,26 type RuntimeMode,
22 type Thread,27 type Thread,
...@@ -27,19 +32,19 @@ import {...@@ -27,19 +32,19 @@ import {
2732
28type Action =33type Action =
29 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }34 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
30 | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }35 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
31 | { type: "TOGGLE_PROJECT"; projectId: string }36 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
32 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }37 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
33 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }38 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
34 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }39 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
35 | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string }40 | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
36 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }41 | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
37 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }42 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string }
38 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }43 | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
39 | { type: "SET_ERROR"; threadId: string; error: string | null }44 | { type: "SET_ERROR"; threadId: ThreadId; error: string | null }
40 | {45 | {
41 type: "SET_THREAD_BRANCH";46 type: "SET_THREAD_BRANCH";
42 threadId: string;47 threadId: ThreadId;
43 branch: string | null;48 branch: string | null;
44 worktreePath: string | null;49 worktreePath: string | null;
45 }50 }
...@@ -127,7 +132,7 @@ function persistState(state: AppState): void {...@@ -127,7 +132,7 @@ function persistState(state: AppState): void {
127132
128function updateThread(133function updateThread(
129 threads: Thread[],134 threads: Thread[],
130 threadId: string,135 threadId: ThreadId,
131 updater: (t: Thread) => Thread,136 updater: (t: Thread) => Thread,
132): Thread[] {137): Thread[] {
133 return threads.map((t) => (t.id === threadId ? updater(t) : t));138 return threads.map((t) => (t.id === threadId ? updater(t) : t));
...@@ -415,7 +420,9 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -415,7 +420,9 @@ export function reducer(state: AppState, action: Action): AppState {
415 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,420 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
416 session: thread.session421 session: thread.session
417 ? {422 ? {
418 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,423 sessionId:
424 thread.session.providerSessionId ??
425 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
419 provider: toLegacyProvider(thread.session.providerName),426 provider: toLegacyProvider(thread.session.providerName),
420 status: toLegacySessionStatus(thread.session.status),427 status: toLegacySessionStatus(thread.session.status),
421 orchestrationStatus: thread.session.status,428 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 {...@@ -2,6 +2,14 @@ import type {
2 OrchestrationSessionStatus,2 OrchestrationSessionStatus,
3 OrchestrationThreadActivity,3 OrchestrationThreadActivity,
4 ProjectScript as ContractProjectScript,4 ProjectScript as ContractProjectScript,
5 ThreadId,
6 ProjectId,
7 TurnId,
8 MessageId,
9 CheckpointRef,
10 ProviderThreadId,
11 ProviderSessionId,
12 ProviderKind,
5} from "@t3tools/contracts";13} from "@t3tools/contracts";
614
7export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";15export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
...@@ -29,7 +37,7 @@ export interface ChatImageAttachment {...@@ -29,7 +37,7 @@ export interface ChatImageAttachment {
29export type ChatAttachment = ChatImageAttachment;37export type ChatAttachment = ChatImageAttachment;
3038
31export interface ChatMessage {39export interface ChatMessage {
32 id: string;40 id: MessageId;
33 role: "user" | "assistant" | "system";41 role: "user" | "assistant" | "system";
34 text: string;42 text: string;
35 attachments?: ChatAttachment[];43 attachments?: ChatAttachment[];
...@@ -46,17 +54,17 @@ export interface TurnDiffFileChange {...@@ -46,17 +54,17 @@ export interface TurnDiffFileChange {
46}54}
4755
48export interface TurnDiffSummary {56export interface TurnDiffSummary {
49 turnId: string;57 turnId: TurnId;
50 completedAt: string;58 completedAt: string;
51 status?: string | undefined;59 status?: string | undefined;
52 files: TurnDiffFileChange[];60 files: TurnDiffFileChange[];
53 checkpointRef?: string | undefined;61 checkpointRef?: CheckpointRef | undefined;
54 assistantMessageId?: string | undefined;62 assistantMessageId?: MessageId | undefined;
55 checkpointTurnCount?: number | undefined;63 checkpointTurnCount?: number | undefined;
56}64}
5765
58export interface Project {66export interface Project {
59 id: string;67 id: ProjectId;
60 name: string;68 name: string;
61 cwd: string;69 cwd: string;
62 model: string;70 model: string;
...@@ -65,9 +73,9 @@ export interface Project {...@@ -65,9 +73,9 @@ export interface Project {
65}73}
6674
67export interface Thread {75export interface Thread {
68 id: string;76 id: ThreadId;
69 codexThreadId: string | null;77 codexThreadId: ProviderThreadId | null;
70 projectId: string;78 projectId: ProjectId;
71 title: string;79 title: string;
72 model: string;80 model: string;
73 terminalOpen: boolean;81 terminalOpen: boolean;
...@@ -81,7 +89,7 @@ export interface Thread {...@@ -81,7 +89,7 @@ export interface Thread {
81 messages: ChatMessage[];89 messages: ChatMessage[];
82 error: string | null;90 error: string | null;
83 createdAt: string;91 createdAt: string;
84 latestTurnId?: string | undefined;92 latestTurnId?: TurnId | undefined;
85 latestTurnStartedAt?: string | undefined;93 latestTurnStartedAt?: string | undefined;
86 latestTurnCompletedAt?: string | undefined;94 latestTurnCompletedAt?: string | undefined;
87 latestTurnDurationMs?: number | undefined;95 latestTurnDurationMs?: number | undefined;
...@@ -93,11 +101,11 @@ export interface Thread {...@@ -93,11 +101,11 @@ export interface Thread {
93}101}
94102
95export interface ThreadSession {103export interface ThreadSession {
96 sessionId: string;104 sessionId: ProviderSessionId;
97 provider: "codex" | "claudeCode";105 provider: ProviderKind;
98 status: SessionPhase | "error" | "closed";106 status: SessionPhase | "error" | "closed";
99 threadId: string | null;107 threadId: ProviderThreadId | null;
100 activeTurnId?: string | undefined;108 activeTurnId?: TurnId | undefined;
101 createdAt: string;109 createdAt: string;
102 updatedAt: string;110 updatedAt: string;
103 lastError?: string;111 lastError?: string;
apps/web/src/worktreeCleanup.test.ts+24-11
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
1import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
23
3import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";4import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
...@@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "...@@ -5,9 +6,9 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "
56
6function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
7 return {8 return {
8 id: "thread-1",9 id: ThreadId.makeUnsafe("thread-1"),
9 codexThreadId: null,10 codexThreadId: null,
10 projectId: "project-1",11 projectId: ProjectId.makeUnsafe("project-1"),
11 title: "Thread",12 title: "Thread",
12 model: "gpt-5.3-codex",13 model: "gpt-5.3-codex",
13 terminalOpen: false,14 terminalOpen: false,
...@@ -36,37 +37,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -36,37 +37,49 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
3637
37describe("getOrphanedWorktreePathForThread", () => {38describe("getOrphanedWorktreePathForThread", () => {
38 it("returns null when the target thread does not exist", () => {39 it("returns null when the target thread does not exist", () => {
39 const result = getOrphanedWorktreePathForThread([], "missing-thread");40 const result = getOrphanedWorktreePathForThread([], ThreadId.makeUnsafe("missing-thread"));
40 expect(result).toBeNull();41 expect(result).toBeNull();
41 });42 });
4243
43 it("returns null when the target thread has no worktree", () => {44 it("returns null when the target thread has no worktree", () => {
44 const threads = [makeThread()];45 const threads = [makeThread()];
45 const result = getOrphanedWorktreePathForThread(threads, "thread-1");46 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
46 expect(result).toBeNull();47 expect(result).toBeNull();
47 });48 });
4849
49 it("returns the path when no other thread links to that worktree", () => {50 it("returns the path when no other thread links to that worktree", () => {
50 const threads = [makeThread({ worktreePath: "/tmp/repo/worktrees/feature-a" })];51 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"));
52 expect(result).toBe("/tmp/repo/worktrees/feature-a");53 expect(result).toBe("/tmp/repo/worktrees/feature-a");
53 });54 });
5455
55 it("returns null when another thread links to the same worktree", () => {56 it("returns null when another thread links to the same worktree", () => {
56 const threads = [57 const threads = [
57 makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }),58 makeThread({
58 makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-a" }),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 }),
59 ];66 ];
60 const result = getOrphanedWorktreePathForThread(threads, "thread-1");67 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
61 expect(result).toBeNull();68 expect(result).toBeNull();
62 });69 });
6370
64 it("ignores threads linked to different worktrees", () => {71 it("ignores threads linked to different worktrees", () => {
65 const threads = [72 const threads = [
66 makeThread({ id: "thread-1", worktreePath: "/tmp/repo/worktrees/feature-a" }),73 makeThread({
67 makeThread({ id: "thread-2", worktreePath: "/tmp/repo/worktrees/feature-b" }),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 }),
68 ];81 ];
69 const result = getOrphanedWorktreePathForThread(threads, "thread-1");82 const result = getOrphanedWorktreePathForThread(threads, ThreadId.makeUnsafe("thread-1"));
70 expect(result).toBe("/tmp/repo/worktrees/feature-a");83 expect(result).toBe("/tmp/repo/worktrees/feature-a");
71 });84 });
72});85});
apps/web/src/worktreeCleanup.ts+1-1
...@@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null {...@@ -10,7 +10,7 @@ function normalizeWorktreePath(path: string | null): string | null {
1010
11export function getOrphanedWorktreePathForThread(11export function getOrphanedWorktreePathForThread(
12 threads: readonly Thread[],12 threads: readonly Thread[],
13 threadId: string,13 threadId: Thread["id"],
14): string | null {14): string | null {
15 const targetThread = threads.find((thread) => thread.id === threadId);15 const targetThread = threads.find((thread) => thread.id === threadId);
16 if (!targetThread) {16 if (!targetThread) {
apps/web/src/wsTransport.ts+1-6
...@@ -13,12 +13,7 @@ const REQUEST_TIMEOUT_MS = 60_000;...@@ -13,12 +13,7 @@ const REQUEST_TIMEOUT_MS = 60_000;
13const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];13const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
14const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse));14const decodeWsResponseFromJson = Schema.decodeUnknownExit(Schema.fromJsonString(WsResponse));
15const isWsPushEnvelope = Schema.is(WsPush);15const isWsPushEnvelope = Schema.is(WsPush);
1616const isWebSocketResponseEnvelope = Schema.is(WebSocketResponse);
17function isWebSocketResponseEnvelope(
18 message: typeof WsResponse.Type,
19): message is WebSocketResponse {
20 return message instanceof WebSocketResponse;
21}
2217
23interface WsRequestEnvelope {18interface WsRequestEnvelope {
24 id: string;19 id: string;
packages/contracts/src/editor.ts+3-2
...@@ -8,7 +8,8 @@ export const EDITORS = [...@@ -8,7 +8,8 @@ export const EDITORS = [
8export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));8export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
9export type EditorId = typeof EditorId.Type;9export type EditorId = typeof EditorId.Type;
1010
11export class OpenInEditorInput extends Schema.Class<OpenInEditorInput>("OpenInEditorInput")({11export const OpenInEditorInput = Schema.Struct({
12 cwd: Schema.String,12 cwd: Schema.String,
13 editor: EditorId,13 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([...@@ -18,78 +18,79 @@ export const GitPrStepStatus = Schema.Literals([
18 "skipped_not_requested",18 "skipped_not_requested",
19]);19]);
2020
21export class GitBranch extends Schema.Class<GitBranch>("GitBranch")({21export const GitBranch = Schema.Struct({
22 name: TrimmedNonEmptyString,22 name: TrimmedNonEmptyString,
23 current: Schema.Boolean,23 current: Schema.Boolean,
24 isDefault: Schema.Boolean,24 isDefault: Schema.Boolean,
25 worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr),25 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({
29 path: TrimmedNonEmptyString,30 path: TrimmedNonEmptyString,
30 branch: TrimmedNonEmptyString,31 branch: TrimmedNonEmptyString,
31}) {}32});
33export type GitWorktree = typeof GitWorktree.Type;
3234
33// RPC Inputs35// RPC Inputs
3436
35export class GitStatusInput extends Schema.Class<GitStatusInput>("GitStatusInput")({37export const GitStatusInput = Schema.Struct({
36 cwd: TrimmedNonEmptyString,38 cwd: TrimmedNonEmptyString,
37}) {}39});
40export type GitStatusInput = typeof GitStatusInput.Type;
3841
39export class GitPullInput extends Schema.Class<GitPullInput>("GitPullInput")({42export const GitPullInput = Schema.Struct({
40 cwd: TrimmedNonEmptyString,43 cwd: TrimmedNonEmptyString,
41}) {}44});
45export type GitPullInput = typeof GitPullInput.Type;
4246
43export class GitRunStackedActionInput extends Schema.Class<GitRunStackedActionInput>(47export const GitRunStackedActionInput = Schema.Struct({
44 "GitRunStackedActionInput",
45)({
46 cwd: TrimmedNonEmptyString,48 cwd: TrimmedNonEmptyString,
47 action: GitStackedAction,49 action: GitStackedAction,
48 commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))),50 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>(54export const GitListBranchesInput = Schema.Struct({
52 "GitListBranchesInput",
53)({
54 cwd: TrimmedNonEmptyString,55 cwd: TrimmedNonEmptyString,
55}) {}56});
57export type GitListBranchesInput = typeof GitListBranchesInput.Type;
5658
57export class GitCreateWorktreeInput extends Schema.Class<GitCreateWorktreeInput>(59export const GitCreateWorktreeInput = Schema.Struct({
58 "GitCreateWorktreeInput",
59)({
60 cwd: TrimmedNonEmptyString,60 cwd: TrimmedNonEmptyString,
61 branch: TrimmedNonEmptyString,61 branch: TrimmedNonEmptyString,
62 newBranch: TrimmedNonEmptyString,62 newBranch: TrimmedNonEmptyString,
63 path: TrimmedNonEmptyString.pipe(Schema.NullOr),63 path: TrimmedNonEmptyString.pipe(Schema.NullOr),
64}) {}64});
65export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
6566
66export class GitRemoveWorktreeInput extends Schema.Class<GitRemoveWorktreeInput>(67export const GitRemoveWorktreeInput = Schema.Struct({
67 "GitRemoveWorktreeInput",
68)({
69 cwd: TrimmedNonEmptyString,68 cwd: TrimmedNonEmptyString,
70 path: TrimmedNonEmptyString,69 path: TrimmedNonEmptyString,
71 force: Schema.optional(Schema.Boolean),70 force: Schema.optional(Schema.Boolean),
72}) {}71});
72export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
7373
74export class GitCreateBranchInput extends Schema.Class<GitCreateBranchInput>(74export const GitCreateBranchInput = Schema.Struct({
75 "GitCreateBranchInput",
76)({
77 cwd: TrimmedNonEmptyString,75 cwd: TrimmedNonEmptyString,
78 branch: TrimmedNonEmptyString,76 branch: TrimmedNonEmptyString,
79}) {}77});
78export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
8079
81export class GitCheckoutInput extends Schema.Class<GitCheckoutInput>("GitCheckoutInput")({80export const GitCheckoutInput = Schema.Struct({
82 cwd: TrimmedNonEmptyString,81 cwd: TrimmedNonEmptyString,
83 branch: TrimmedNonEmptyString,82 branch: TrimmedNonEmptyString,
84}) {}83});
84export type GitCheckoutInput = typeof GitCheckoutInput.Type;
8585
86export class GitInitInput extends Schema.Class<GitInitInput>("GitInitInput")({86export const GitInitInput = Schema.Struct({
87 cwd: TrimmedNonEmptyString,87 cwd: TrimmedNonEmptyString,
88}) {}88});
89export type GitInitInput = typeof GitInitInput.Type;
8990
90// RPC Results91// RPC Results
9192
92export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusResult")({93export const GitStatusResult = Schema.Struct({
93 branch: TrimmedNonEmptyString.pipe(Schema.NullOr),94 branch: TrimmedNonEmptyString.pipe(Schema.NullOr),
94 hasWorkingTreeChanges: Schema.Boolean,95 hasWorkingTreeChanges: Schema.Boolean,
95 workingTree: Schema.Struct({96 workingTree: Schema.Struct({
...@@ -113,24 +114,21 @@ export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusRes...@@ -113,24 +114,21 @@ export class GitStatusResult extends Schema.Class<GitStatusResult>("GitStatusRes
113 baseBranch: TrimmedNonEmptyString,114 baseBranch: TrimmedNonEmptyString,
114 headBranch: TrimmedNonEmptyString,115 headBranch: TrimmedNonEmptyString,
115 }).pipe(Schema.NullOr),116 }).pipe(Schema.NullOr),
116}) {}117});
118export type GitStatusResult = typeof GitStatusResult.Type;
117119
118export class GitListBranchesResult extends Schema.Class<GitListBranchesResult>(120export const GitListBranchesResult = Schema.Struct({
119 "GitListBranchesResult",
120)({
121 branches: Schema.Array(GitBranch),121 branches: Schema.Array(GitBranch),
122 isRepo: Schema.Boolean,122 isRepo: Schema.Boolean,
123}) {}123});
124export type GitListBranchesResult = typeof GitListBranchesResult.Type;
124125
125export class GitCreateWorktreeResult extends Schema.Class<GitCreateWorktreeResult>(126export const GitCreateWorktreeResult = Schema.Struct({
126 "GitCreateWorktreeResult",
127)({
128 worktree: GitWorktree,127 worktree: GitWorktree,
129}) {}128});
129export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
130130
131export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionResult>(131export const GitRunStackedActionResult = Schema.Struct({
132 "GitRunStackedActionResult",
133)({
134 action: GitStackedAction,132 action: GitStackedAction,
135 commit: Schema.Struct({133 commit: Schema.Struct({
136 status: GitCommitStepStatus,134 status: GitCommitStepStatus,
...@@ -151,10 +149,12 @@ export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionR...@@ -151,10 +149,12 @@ export class GitRunStackedActionResult extends Schema.Class<GitRunStackedActionR
151 headBranch: Schema.optional(TrimmedNonEmptyString),149 headBranch: Schema.optional(TrimmedNonEmptyString),
152 title: Schema.optional(TrimmedNonEmptyString),150 title: Schema.optional(TrimmedNonEmptyString),
153 }),151 }),
154}) {}152});
153export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
155154
156export class GitPullResult extends Schema.Class<GitPullResult>("GitPullResult")({155export const GitPullResult = Schema.Struct({
157 status: Schema.Literals(["pulled", "skipped_up_to_date"]),156 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
158 branch: TrimmedNonEmptyString,157 branch: TrimmedNonEmptyString,
159 upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr),158 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(...@@ -44,25 +44,27 @@ export const KeybindingWhen = Schema.Trim.check(
44 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),44 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
45);45);
46export type KeybindingWhen = typeof KeybindingWhen.Type;46export type KeybindingWhen = typeof KeybindingWhen.Type;
47export class KeybindingRule extends Schema.Class<KeybindingRule>("KeybindingRule")({47export const KeybindingRule = Schema.Struct({
48 key: KeybindingValue,48 key: KeybindingValue,
49 command: KeybindingCommand,49 command: KeybindingCommand,
50 when: Schema.optional(KeybindingWhen),50 when: Schema.optional(KeybindingWhen),
51}) {}51});
52export type KeybindingRule = typeof KeybindingRule.Type;
5253
53export const KeybindingsConfig = Schema.Array(KeybindingRule).check(54export const KeybindingsConfig = Schema.Array(KeybindingRule).check(
54 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),55 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
55);56);
56export type KeybindingsConfig = typeof KeybindingsConfig.Type;57export type KeybindingsConfig = typeof KeybindingsConfig.Type;
5758
58export class KeybindingShortcut extends Schema.Class<KeybindingShortcut>("KeybindingShortcut")({59export const KeybindingShortcut = Schema.Struct({
59 key: KeybindingValue,60 key: KeybindingValue,
60 metaKey: Schema.Boolean,61 metaKey: Schema.Boolean,
61 ctrlKey: Schema.Boolean,62 ctrlKey: Schema.Boolean,
62 shiftKey: Schema.Boolean,63 shiftKey: Schema.Boolean,
63 altKey: Schema.Boolean,64 altKey: Schema.Boolean,
64 modKey: Schema.Boolean,65 modKey: Schema.Boolean,
65}) {}66});
67export type KeybindingShortcut = typeof KeybindingShortcut.Type;
6668
67export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([69export const KeybindingWhenNode: Schema.Schema<KeybindingWhenNode> = Schema.Union([
68 Schema.Struct({70 Schema.Struct({
...@@ -90,16 +92,12 @@ export type KeybindingWhenNode =...@@ -90,16 +92,12 @@ export type KeybindingWhenNode =
90 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }92 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
91 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };93 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
9294
93export class ResolvedKeybindingRule extends Schema.Class<ResolvedKeybindingRule>(95export const ResolvedKeybindingRule = Schema.Struct({
94 "ResolvedKeybindingRule",96 command: KeybindingCommand,
95)(97 shortcut: KeybindingShortcut,
96 {98 whenAst: Schema.optional(KeybindingWhenNode),
97 command: KeybindingCommand,99}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
98 shortcut: KeybindingShortcut,100export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;
99 whenAst: Schema.optional(KeybindingWhenNode),
100 },
101 { parseOptions: { onExcessProperty: "ignore" } },
102) {}
103101
104export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(102export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
105 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),103 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
packages/contracts/src/model.ts+4
...@@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug {...@@ -37,3 +37,7 @@ export function resolveModelSlug(model: string | null | undefined): ModelSlug {
3737
38 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;38 return MODEL_OPTIONS.some((option) => option.slug === normalized) ? normalized : DEFAULT_MODEL;
39}39}
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 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import { Option, Schema, SchemaIssue, Struct } from "effect";
22
3export const ORCHESTRATION_WS_METHODS = {3export const ORCHESTRATION_WS_METHODS = {
4 getSnapshot: "orchestration.getSnapshot",4 getSnapshot: "orchestration.getSnapshot",
...@@ -777,13 +777,24 @@ export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandRecei...@@ -777,13 +777,24 @@ export type OrchestrationCommandReceiptStatus = typeof OrchestrationCommandRecei
777export const TurnCountRange = Schema.Struct({777export const TurnCountRange = Schema.Struct({
778 fromTurnCount: NonNegativeInt,778 fromTurnCount: NonNegativeInt,
779 toTurnCount: NonNegativeInt,779 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({791export const ThreadTurnDiff = TurnCountRange.mapFields(
783 threadId: ThreadId,792 Struct.assign({
784 ...TurnCountRange.fields,793 threadId: ThreadId,
785 diff: Schema.String,794 diff: Schema.String,
786});795 }),
796 { unsafePreserveChecks: true },
797);
787798
788export const ProviderSessionRuntimeStatus = Schema.Literals([799export const ProviderSessionRuntimeStatus = Schema.Literals([
789 "starting",800 "starting",
...@@ -829,10 +840,10 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput...@@ -829,10 +840,10 @@ export type OrchestrationGetSnapshotInput = typeof OrchestrationGetSnapshotInput
829export const OrchestrationGetSnapshotResult = OrchestrationReadModel;840export const OrchestrationGetSnapshotResult = OrchestrationReadModel;
830export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type;841export type OrchestrationGetSnapshotResult = typeof OrchestrationGetSnapshotResult.Type;
831842
832export const OrchestrationGetTurnDiffInput = Schema.Struct({843export const OrchestrationGetTurnDiffInput = TurnCountRange.mapFields(
833 threadId: ThreadId,844 Struct.assign({ threadId: ThreadId }),
834 ...TurnCountRange.fields,845 { unsafePreserveChecks: true },
835});846);
836export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;847export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput.Type;
837848
838export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;849export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
packages/contracts/src/project.ts+9-10
...@@ -3,28 +3,27 @@ import { Schema } from "effect";...@@ -3,28 +3,27 @@ import { Schema } from "effect";
3export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;3export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
4const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());4const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
55
6export class ProjectSearchEntriesInput extends Schema.Class<ProjectSearchEntriesInput>(6export const ProjectSearchEntriesInput = Schema.Struct({
7 "ProjectSearchEntriesInput",
8)({
9 cwd: TrimmedNonEmptyString,7 cwd: TrimmedNonEmptyString,
10 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),8 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
11 limit: Schema.Int.check(9 limit: Schema.Int.check(
12 Schema.isGreaterThanOrEqualTo(1),10 Schema.isGreaterThanOrEqualTo(1),
13 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),11 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),
14 ),12 ),
15}) {}13});
14export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
1615
17export const ProjectEntryKind = Schema.Literals(["file", "directory"]);16export const ProjectEntryKind = Schema.Literals(["file", "directory"]);
1817
19export class ProjectEntry extends Schema.Class<ProjectEntry>("ProjectEntry")({18export const ProjectEntry = Schema.Struct({
20 path: TrimmedNonEmptyString,19 path: TrimmedNonEmptyString,
21 kind: ProjectEntryKind,20 kind: ProjectEntryKind,
22 parentPath: Schema.optional(TrimmedNonEmptyString),21 parentPath: Schema.optional(TrimmedNonEmptyString),
23}) {}22});
23export type ProjectEntry = typeof ProjectEntry.Type;
2424
25export class ProjectSearchEntriesResult extends Schema.Class<ProjectSearchEntriesResult>(25export const ProjectSearchEntriesResult = Schema.Struct({
26 "ProjectSearchEntriesResult",
27)({
28 entries: Schema.Array(ProjectEntry),26 entries: Schema.Array(ProjectEntry),
29 truncated: Schema.Boolean,27 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 @@...@@ -1,4 +1,4 @@
1import { Schema } from "effect";1import { Schema, Struct } from "effect";
22
3import {3import {
4 ClientOrchestrationCommand,4 ClientOrchestrationCommand,
...@@ -76,43 +76,56 @@ export const WS_CHANNELS = {...@@ -76,43 +76,56 @@ export const WS_CHANNELS = {
7676
77// -- Tagged Union of all request body schemas ─────────────────────────77// -- 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([
80 // Orchestration methods90 // Orchestration methods
81 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },91 tagRequestBody(
82 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,92 ORCHESTRATION_WS_METHODS.dispatchCommand,
83 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,93 Schema.Struct({ command: ClientOrchestrationCommand }),
84 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields,94 ),
85 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,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
87 // Project Search100 // Project Search
88 [WS_METHODS.projectsSearchEntries]: ProjectSearchEntriesInput.fields,101 tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput),
89102
90 // Shell methods103 // Shell methods
91 [WS_METHODS.shellOpenInEditor]: OpenInEditorInput.fields,104 tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput),
92105
93 // Git methods106 // Git methods
94 [WS_METHODS.gitPull]: GitPullInput.fields,107 tagRequestBody(WS_METHODS.gitPull, GitPullInput),
95 [WS_METHODS.gitStatus]: GitStatusInput.fields,108 tagRequestBody(WS_METHODS.gitStatus, GitStatusInput),
96 [WS_METHODS.gitRunStackedAction]: GitRunStackedActionInput.fields,109 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
97 [WS_METHODS.gitListBranches]: GitListBranchesInput.fields,110 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
98 [WS_METHODS.gitCreateWorktree]: GitCreateWorktreeInput.fields,111 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
99 [WS_METHODS.gitRemoveWorktree]: GitRemoveWorktreeInput.fields,112 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
100 [WS_METHODS.gitCreateBranch]: GitCreateBranchInput.fields,113 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
101 [WS_METHODS.gitCheckout]: GitCheckoutInput.fields,114 tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),
102 [WS_METHODS.gitInit]: GitInitInput.fields,115 tagRequestBody(WS_METHODS.gitInit, GitInitInput),
103116
104 // Terminal methods117 // Terminal methods
105 [WS_METHODS.terminalOpen]: TerminalOpenInput.fields,118 tagRequestBody(WS_METHODS.terminalOpen, TerminalOpenInput),
106 [WS_METHODS.terminalWrite]: TerminalWriteInput.fields,119 tagRequestBody(WS_METHODS.terminalWrite, TerminalWriteInput),
107 [WS_METHODS.terminalResize]: TerminalResizeInput.fields,120 tagRequestBody(WS_METHODS.terminalResize, TerminalResizeInput),
108 [WS_METHODS.terminalClear]: TerminalClearInput.fields,121 tagRequestBody(WS_METHODS.terminalClear, TerminalClearInput),
109 [WS_METHODS.terminalRestart]: TerminalRestartInput.fields,122 tagRequestBody(WS_METHODS.terminalRestart, TerminalRestartInput),
110 [WS_METHODS.terminalClose]: TerminalCloseInput.fields,123 tagRequestBody(WS_METHODS.terminalClose, TerminalCloseInput),
111124
112 // Server meta125 // Server meta
113 [WS_METHODS.serverGetConfig]: {},126 tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})),
114 [WS_METHODS.serverUpsertKeybinding]: KeybindingRule.fields,127 tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule),
115});128]);
116129
117export const WebSocketRequest = Schema.Struct({130export const WebSocketRequest = Schema.Struct({
118 id: Schema.String,131 id: Schema.String,
...@@ -120,7 +133,7 @@ export const WebSocketRequest = Schema.Struct({...@@ -120,7 +133,7 @@ export const WebSocketRequest = Schema.Struct({
120});133});
121export type WebSocketRequest = typeof WebSocketRequest.Type;134export type WebSocketRequest = typeof WebSocketRequest.Type;
122135
123export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocketResponse")({136export const WebSocketResponse = Schema.Struct({
124 id: Schema.String,137 id: Schema.String,
125 result: Schema.optional(Schema.Unknown),138 result: Schema.optional(Schema.Unknown),
126 error: Schema.optional(139 error: Schema.optional(
...@@ -128,13 +141,15 @@ export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocke...@@ -128,13 +141,15 @@ export class WebSocketResponse extends Schema.Class<WebSocketResponse>("WebSocke
128 message: Schema.String,141 message: Schema.String,
129 }),142 }),
130 ),143 ),
131}) {}144});
145export type WebSocketResponse = typeof WebSocketResponse.Type;
132146
133export class WsPush extends Schema.Class<WsPush>("WsPushEvent")({147export const WsPush = Schema.Struct({
134 type: Schema.Literal("push"),148 type: Schema.Literal("push"),
135 channel: Schema.String,149 channel: Schema.String,
136 data: Schema.Unknown,150 data: Schema.Unknown,
137}) {}151});
152export type WsPush = typeof WsPush.Type;
138153
139// ── Union of all server → client messages ─────────────────────────────154// ── Union of all server → client messages ─────────────────────────────
140155
...@@ -143,7 +158,8 @@ export type WsResponse = typeof WsResponse.Type;...@@ -143,7 +158,8 @@ export type WsResponse = typeof WsResponse.Type;
143158
144// ── Server welcome payload ───────────────────────────────────────────159// ── Server welcome payload ───────────────────────────────────────────
145160
146export class WsWelcomePayload extends Schema.Class<WsWelcomePayload>("WsWelcomePayload")({161export const WsWelcomePayload = Schema.Struct({
147 cwd: Schema.String,162 cwd: Schema.String,
148 projectName: Schema.String,163 projectName: Schema.String,
149}) {}164});
165export type WsWelcomePayload = typeof WsWelcomePayload.Type;