authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-21 17:10:49-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log483a1ae3718e39269075e99ea84e437d4cfad591
tree65661a7e3c4a3b207504479fd72c9e766e87ea04
parent781006f0054f3e40ec7f7eadd612b037ea52f5ff

Add server-side orchestration engine with event sourcing

Move state management from client to server using an event-sourced architecture: - Add SQLite-backed event store with append-only event log - Add orchestration engine with command queue, reducer, and read model - Add Effect-based PubSub for real-time read model updates to clients - Define orchestration schemas in contracts (commands, events, read model) - Simplify client-side store by removing persistence layer - Remove obsolete client-side persistence tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

33 files changed, 1688 insertions(+), 4546 deletions(-)

README.md+2-2
......@@ -106,12 +106,12 @@ T3 Code has a global runtime mode switch in the chat toolbar:
106106The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
107107
108108- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }`
109- **Push events**: `{ type: "push", channel, data }` for streaming provider events
109- **Push events**: `{ type: "push", channel, data }` for orchestration read-model updates
110110
111111Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`:
112112
113113- `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn`
114- `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions`
114- `providers.respondToRequest`, `providers.stopSession`
115115- `shell.openInEditor`, `server.getConfig`
116116
117117Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI.
apps/server/package.json+3
......@@ -17,6 +17,8 @@
1717 "test": "vitest run"
1818 },
1919 "dependencies": {
20 "better-sqlite3": "^12.6.2",
21 "effect": "^3.19.19",
2022 "node-pty": "^1.1.0",
2123 "open": "^10.1.0",
2224 "ws": "^8.18.0"
......@@ -24,6 +26,7 @@
2426 "devDependencies": {
2527 "@t3tools/contracts": "workspace:*",
2628 "@t3tools/web": "workspace:*",
29 "@types/better-sqlite3": "^7.6.13",
2730 "@types/node": "^22.10.2",
2831 "@types/ws": "^8.5.13",
2932 "tsdown": "^0.20.3",
apps/server/src/index.ts+1
......@@ -118,6 +118,7 @@ async function main() {
118118 port,
119119 host: mode === "desktop" ? "127.0.0.1" : undefined,
120120 cwd,
121 stateDir,
121122 staticDir,
122123 devUrl,
123124 projectRegistry,
apps/server/src/orchestration/engine.test.ts created+119
......@@ -0,0 +1,119 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import { OrchestrationEngine } from "./engine";
8
9const tempDirs: string[] = [];
10
11function makeTempDir(prefix: string): string {
12 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
13 tempDirs.push(dir);
14 return dir;
15}
16
17afterEach(() => {
18 for (const dir of tempDirs.splice(0, tempDirs.length)) {
19 fs.rmSync(dir, { recursive: true, force: true });
20 }
21});
22
23describe("OrchestrationEngine", () => {
24 it("replays to the same deterministic snapshot", async () => {
25 const stateDir = makeTempDir("t3code-orchestration-");
26 const createdAt = new Date().toISOString();
27 const projectId = "project-1";
28 const threadId = "thread-1";
29
30 const engineA = new OrchestrationEngine(stateDir);
31 await engineA.start();
32 await engineA.dispatch({
33 type: "project.create",
34 commandId: "cmd-1",
35 projectId,
36 name: "demo",
37 cwd: "/tmp/demo",
38 model: "gpt-5-codex",
39 createdAt,
40 });
41 await engineA.dispatch({
42 type: "thread.create",
43 commandId: "cmd-2",
44 threadId,
45 projectId,
46 title: "Thread",
47 model: "gpt-5-codex",
48 branch: null,
49 worktreePath: null,
50 createdAt,
51 });
52 await engineA.dispatch({
53 type: "message.send",
54 commandId: "cmd-3",
55 threadId,
56 messageId: "msg-1",
57 role: "user",
58 text: "hello",
59 streaming: false,
60 createdAt,
61 });
62 const snapshotA = engineA.getSnapshot();
63 await engineA.stop();
64
65 const engineB = new OrchestrationEngine(stateDir);
66 await engineB.start();
67 const snapshotB = engineB.getSnapshot();
68 expect(snapshotB).toEqual(snapshotA);
69 await engineB.stop();
70 });
71
72 it("fans out read-model updates to subscribers", async () => {
73 const stateDir = makeTempDir("t3code-orchestration-fanout-");
74 const engine = new OrchestrationEngine(stateDir);
75 await engine.start();
76 const updates: number[] = [];
77 const unsubscribe = engine.subscribeToReadModel((snapshot) => {
78 updates.push(snapshot.sequence);
79 });
80 await engine.dispatch({
81 type: "project.create",
82 commandId: "cmd-project",
83 projectId: "project-2",
84 name: "fanout",
85 cwd: "/tmp/fanout",
86 model: "gpt-5-codex",
87 createdAt: new Date().toISOString(),
88 });
89 unsubscribe();
90 expect(updates.length).toBeGreaterThan(0);
91 await engine.stop();
92 });
93
94 it("replays append-only events from sequence", async () => {
95 const stateDir = makeTempDir("t3code-orchestration-replay-");
96 const engine = new OrchestrationEngine(stateDir);
97 await engine.start();
98 await engine.dispatch({
99 type: "project.create",
100 commandId: "cmd-a",
101 projectId: "project-replay",
102 name: "replay",
103 cwd: "/tmp/replay",
104 model: "gpt-5-codex",
105 createdAt: new Date().toISOString(),
106 });
107 await engine.dispatch({
108 type: "project.delete",
109 commandId: "cmd-b",
110 projectId: "project-replay",
111 createdAt: new Date().toISOString(),
112 });
113 const events = await engine.replayEvents(0);
114 expect(events.length).toBe(2);
115 expect(events[0]?.type).toBe("project.created");
116 expect(events[1]?.type).toBe("project.deleted");
117 await engine.stop();
118 });
119});
apps/server/src/orchestration/engine.ts created+274
......@@ -0,0 +1,274 @@
1import path from "node:path";
2
3import type {
4 OrchestrationCommand,
5 OrchestrationEvent,
6 OrchestrationReadModel,
7} from "@t3tools/contracts";
8import { OrchestrationCommandSchema } from "@t3tools/contracts";
9import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect";
10
11import { createLogger } from "../logger";
12import { SqliteEventStore } from "./eventStore";
13import { createEmptyReadModel, reduceEvent } from "./reducer";
14import { UI_ENTITY_CONTRACTS } from "./uiContractInventory";
15
16type CommandEnvelope = {
17 command: OrchestrationCommand;
18 resolve: (result: { sequence: number }) => void;
19 reject: (error: Error) => void;
20};
21
22function mapCommandToEvent(command: OrchestrationCommand): Omit<OrchestrationEvent, "sequence"> {
23 const eventId = crypto.randomUUID();
24 switch (command.type) {
25 case "project.create":
26 return {
27 eventId,
28 type: "project.created",
29 aggregateType: "project",
30 aggregateId: command.projectId,
31 occurredAt: command.createdAt,
32 commandId: command.commandId,
33 payload: {
34 id: command.projectId,
35 name: command.name,
36 cwd: command.cwd,
37 model: command.model,
38 createdAt: command.createdAt,
39 updatedAt: command.createdAt,
40 },
41 };
42 case "project.delete":
43 return {
44 eventId,
45 type: "project.deleted",
46 aggregateType: "project",
47 aggregateId: command.projectId,
48 occurredAt: command.createdAt,
49 commandId: command.commandId,
50 payload: {
51 id: command.projectId,
52 deletedAt: command.createdAt,
53 },
54 };
55 case "thread.create":
56 return {
57 eventId,
58 type: "thread.created",
59 aggregateType: "thread",
60 aggregateId: command.threadId,
61 occurredAt: command.createdAt,
62 commandId: command.commandId,
63 payload: {
64 id: command.threadId,
65 projectId: command.projectId,
66 title: command.title,
67 model: command.model,
68 branch: command.branch,
69 worktreePath: command.worktreePath,
70 createdAt: command.createdAt,
71 updatedAt: command.createdAt,
72 },
73 };
74 case "thread.delete":
75 return {
76 eventId,
77 type: "thread.deleted",
78 aggregateType: "thread",
79 aggregateId: command.threadId,
80 occurredAt: command.createdAt,
81 commandId: command.commandId,
82 payload: {
83 id: command.threadId,
84 deletedAt: command.createdAt,
85 },
86 };
87 case "thread.meta.update":
88 return {
89 eventId,
90 type: "thread.meta-updated",
91 aggregateType: "thread",
92 aggregateId: command.threadId,
93 occurredAt: command.createdAt,
94 commandId: command.commandId,
95 payload: {
96 threadId: command.threadId,
97 ...(command.title !== undefined ? { title: command.title } : {}),
98 ...(command.model !== undefined ? { model: command.model } : {}),
99 ...(command.branch !== undefined ? { branch: command.branch } : {}),
100 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
101 updatedAt: command.createdAt,
102 },
103 };
104 case "message.send":
105 return {
106 eventId,
107 type: "message.sent",
108 aggregateType: "thread",
109 aggregateId: command.threadId,
110 occurredAt: command.createdAt,
111 commandId: command.commandId,
112 payload: {
113 id: command.messageId,
114 role: command.role,
115 text: command.text,
116 threadId: command.threadId,
117 createdAt: command.createdAt,
118 streaming: command.streaming === true,
119 },
120 };
121 case "thread.session":
122 return {
123 eventId,
124 type: "thread.session-set",
125 aggregateType: "thread",
126 aggregateId: command.threadId,
127 occurredAt: command.createdAt,
128 commandId: command.commandId,
129 payload: {
130 threadId: command.threadId,
131 session: command.session,
132 },
133 };
134 case "git.readModel.upsert":
135 return {
136 eventId,
137 type: "git.read-model-upsert",
138 aggregateType: "project",
139 aggregateId: command.projectId,
140 occurredAt: command.createdAt,
141 commandId: command.commandId,
142 payload: {
143 projectId: command.projectId,
144 branch: command.branch,
145 hasWorkingTreeChanges: command.hasWorkingTreeChanges,
146 aheadCount: command.aheadCount,
147 behindCount: command.behindCount,
148 updatedAt: command.createdAt,
149 },
150 };
151 }
152}
153
154export class OrchestrationEngine {
155 private readonly logger = createLogger("orchestration");
156 private readonly runtime = Runtime.defaultRuntime;
157 private readonly eventStore: SqliteEventStore;
158
159 private readModel: OrchestrationReadModel;
160 private commandQueue: Queue.Queue<CommandEnvelope>;
161 private readModelPubSub: PubSub.PubSub<OrchestrationReadModel>;
162 private eventPubSub: PubSub.PubSub<OrchestrationEvent>;
163 private workerFiber: Fiber.RuntimeFiber<void, unknown> | null = null;
164 private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>();
165 private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>();
166
167 constructor(stateDir: string) {
168 const dbPath = path.join(stateDir, "orchestration.sqlite");
169 this.eventStore = new SqliteEventStore(dbPath);
170 this.readModel = createEmptyReadModel(new Date().toISOString());
171 this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded<CommandEnvelope>());
172 this.readModelPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded<OrchestrationReadModel>());
173 this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded<OrchestrationEvent>());
174 }
175
176 async start(): Promise<void> {
177 const existingEvents = await Runtime.runPromise(this.runtime)(this.eventStore.readAll());
178 for (const event of existingEvents) {
179 this.readModel = reduceEvent(this.readModel, event);
180 }
181
182 const worker = Stream.fromQueue(this.commandQueue).pipe(
183 Stream.runForEach((envelope) =>
184 Effect.tryPromise({
185 try: async () => {
186 const eventBase = mapCommandToEvent(envelope.command);
187 const savedEvent = await Runtime.runPromise(this.runtime)(this.eventStore.append(eventBase));
188 this.readModel = reduceEvent(this.readModel, savedEvent);
189 await Promise.all([
190 Runtime.runPromise(this.runtime)(PubSub.publish(this.eventPubSub, savedEvent)),
191 Runtime.runPromise(this.runtime)(PubSub.publish(this.readModelPubSub, this.readModel)),
192 ]);
193 for (const listener of this.domainEventListeners) {
194 listener(savedEvent);
195 }
196 for (const listener of this.readModelListeners) {
197 listener(this.readModel);
198 }
199 envelope.resolve({ sequence: savedEvent.sequence });
200 },
201 catch: (error) => {
202 const message = error instanceof Error ? error.message : "Unknown command processing error";
203 envelope.reject(new Error(message));
204 return undefined;
205 },
206 }),
207 ),
208 );
209
210 this.workerFiber = Runtime.runFork(this.runtime)(worker);
211 this.logger.info("orchestration engine started", {
212 sequence: this.readModel.sequence,
213 contracts: UI_ENTITY_CONTRACTS.length,
214 });
215 }
216
217 async stop(): Promise<void> {
218 if (this.workerFiber) {
219 await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber));
220 this.workerFiber = null;
221 }
222 }
223
224 getSnapshot(): OrchestrationReadModel {
225 return this.readModel;
226 }
227
228 async replayEvents(fromSequenceExclusive: number): Promise<OrchestrationEvent[]> {
229 return Runtime.runPromise(this.runtime)(this.eventStore.readFromSequence(fromSequenceExclusive));
230 }
231
232 async dispatchUnknown(command: unknown): Promise<{ sequence: number }> {
233 const decode = Schema.decodeUnknownEither(OrchestrationCommandSchema);
234 let payload: unknown = command;
235 if (typeof command === "string") {
236 try {
237 payload = JSON.parse(command) as unknown;
238 } catch {
239 throw new Error("Invalid orchestration command: payload is not valid JSON");
240 }
241 }
242 const decoded = decode(payload);
243 if (Either.isLeft(decoded)) {
244 const issues = decoded.left.toString();
245 throw new Error(`Invalid orchestration command: ${issues}`);
246 }
247 return this.dispatch(decoded.right);
248 }
249
250 async dispatch(command: OrchestrationCommand): Promise<{ sequence: number }> {
251 return new Promise<{ sequence: number }>((resolve, reject) => {
252 Runtime.runPromise(this.runtime)(
253 Queue.offer(this.commandQueue, { command, resolve, reject }),
254 ).catch((error) => {
255 const message = error instanceof Error ? error.message : "Queue offer failed";
256 reject(new Error(message));
257 });
258 });
259 }
260
261 subscribeToReadModel(callback: (snapshot: OrchestrationReadModel) => void): () => void {
262 this.readModelListeners.add(callback);
263 return () => {
264 this.readModelListeners.delete(callback);
265 };
266 }
267
268 subscribeToDomainEvents(callback: (event: OrchestrationEvent) => void): () => void {
269 this.domainEventListeners.add(callback);
270 return () => {
271 this.domainEventListeners.delete(callback);
272 };
273 }
274}
apps/server/src/orchestration/eventStore.ts created+102
......@@ -0,0 +1,102 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import type { OrchestrationEvent } from "@t3tools/contracts";
5import Database from "better-sqlite3";
6import { Effect } from "effect";
7
8interface EventRow {
9 sequence: number;
10 event_id: string;
11 event_type: string;
12 aggregate_type: string;
13 aggregate_id: string;
14 occurred_at: string;
15 command_id: string | null;
16 payload_json: string;
17}
18
19export class SqliteEventStore {
20 private readonly db: Database.Database;
21
22 constructor(dbPath: string) {
23 fs.mkdirSync(path.dirname(dbPath), { recursive: true });
24 this.db = new Database(dbPath);
25 this.db.pragma("journal_mode = WAL");
26 this.db.pragma("foreign_keys = ON");
27 this.migrate();
28 }
29
30 private migrate(): void {
31 this.db.exec(`
32 CREATE TABLE IF NOT EXISTS orchestration_events (
33 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
34 event_id TEXT NOT NULL UNIQUE,
35 event_type TEXT NOT NULL,
36 aggregate_type TEXT NOT NULL,
37 aggregate_id TEXT NOT NULL,
38 occurred_at TEXT NOT NULL,
39 command_id TEXT,
40 payload_json TEXT NOT NULL
41 );
42
43 CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate
44 ON orchestration_events(aggregate_type, aggregate_id, sequence);
45 `);
46 }
47
48 append(event: Omit<OrchestrationEvent, "sequence">): Effect.Effect<OrchestrationEvent> {
49 return Effect.sync(() => {
50 const stmt = this.db.prepare(`
51 INSERT INTO orchestration_events (
52 event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json
53 )
54 VALUES (?, ?, ?, ?, ?, ?, ?)
55 `);
56 const result = stmt.run(
57 event.eventId,
58 event.type,
59 event.aggregateType,
60 event.aggregateId,
61 event.occurredAt,
62 event.commandId,
63 JSON.stringify(event.payload),
64 );
65
66 return {
67 ...event,
68 sequence: Number(result.lastInsertRowid),
69 };
70 });
71 }
72
73 readFromSequence(sequenceExclusive: number, limit = 1_000): Effect.Effect<OrchestrationEvent[]> {
74 return Effect.sync(() => {
75 const rows = this.db
76 .prepare(
77 `
78 SELECT sequence, event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json
79 FROM orchestration_events
80 WHERE sequence > ?
81 ORDER BY sequence ASC
82 LIMIT ?
83 `,
84 )
85 .all(sequenceExclusive, limit) as EventRow[];
86 return rows.map((row) => ({
87 sequence: row.sequence,
88 eventId: row.event_id,
89 type: row.event_type,
90 aggregateType: row.aggregate_type,
91 aggregateId: row.aggregate_id,
92 occurredAt: row.occurred_at,
93 commandId: row.command_id,
94 payload: JSON.parse(row.payload_json) as unknown,
95 }));
96 });
97 }
98
99 readAll(): Effect.Effect<OrchestrationEvent[]> {
100 return this.readFromSequence(0, Number.MAX_SAFE_INTEGER);
101 }
102}
apps/server/src/orchestration/reducer.ts created+217
......@@ -0,0 +1,217 @@
1import type {
2 OrchestrationEvent,
3 OrchestrationGitReadModel,
4 OrchestrationMessage,
5 OrchestrationProject,
6 OrchestrationReadModel,
7 OrchestrationSession,
8 OrchestrationThread,
9} from "@t3tools/contracts";
10
11type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;
12
13function updateThread(
14 threads: ReadonlyArray<OrchestrationThread>,
15 threadId: string,
16 patch: ThreadPatch,
17): OrchestrationThread[] {
18 return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));
19}
20
21function asObject(value: unknown): Record<string, unknown> | null {
22 if (!value || typeof value !== "object") return null;
23 return value as Record<string, unknown>;
24}
25
26function asString(value: unknown): string | null {
27 return typeof value === "string" ? value : null;
28}
29
30function asNumber(value: unknown): number | null {
31 return typeof value === "number" && Number.isFinite(value) ? value : null;
32}
33
34export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
35 return {
36 sequence: 0,
37 projects: [],
38 threads: [],
39 gitByProjectId: {},
40 updatedAt: nowIso,
41 };
42}
43
44export function reduceEvent(
45 model: OrchestrationReadModel,
46 event: OrchestrationEvent,
47): OrchestrationReadModel {
48 const payload = asObject(event.payload);
49 const nextBase: OrchestrationReadModel = {
50 ...model,
51 sequence: event.sequence,
52 updatedAt: event.occurredAt,
53 };
54
55 switch (event.type) {
56 case "project.created": {
57 if (!payload) return nextBase;
58 const project: OrchestrationProject = {
59 id: asString(payload.id) ?? event.aggregateId,
60 name: asString(payload.name) ?? "project",
61 cwd: asString(payload.cwd) ?? "",
62 model: asString(payload.model) ?? "gpt-5-codex",
63 createdAt: asString(payload.createdAt) ?? event.occurredAt,
64 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
65 };
66 const existing = nextBase.projects.find((entry) => entry.id === project.id);
67 return {
68 ...nextBase,
69 projects: existing
70 ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry))
71 : [...nextBase.projects, project],
72 };
73 }
74 case "project.deleted":
75 return {
76 ...nextBase,
77 projects: nextBase.projects.filter((project) => project.id !== event.aggregateId),
78 threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId),
79 };
80 case "thread.created": {
81 if (!payload) return nextBase;
82 const thread: OrchestrationThread = {
83 id: asString(payload.id) ?? event.aggregateId,
84 projectId: asString(payload.projectId) ?? "",
85 title: asString(payload.title) ?? "New thread",
86 model: asString(payload.model) ?? "gpt-5-codex",
87 branch: asString(payload.branch),
88 worktreePath: asString(payload.worktreePath),
89 createdAt: asString(payload.createdAt) ?? event.occurredAt,
90 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
91 latestTurnId: null,
92 latestTurnStartedAt: null,
93 latestTurnCompletedAt: null,
94 latestTurnDurationMs: null,
95 messages: [],
96 session: null,
97 turnDiffSummaries: [],
98 error: null,
99 };
100 const existing = nextBase.threads.find((entry) => entry.id === thread.id);
101 return {
102 ...nextBase,
103 threads: existing
104 ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry))
105 : [...nextBase.threads, thread],
106 };
107 }
108 case "thread.deleted":
109 return {
110 ...nextBase,
111 threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId),
112 };
113 case "thread.meta-updated": {
114 if (!payload) return nextBase;
115 const threadId = asString(payload.threadId) ?? event.aggregateId;
116 const thread = nextBase.threads.find((entry) => entry.id === threadId);
117 if (!thread) return nextBase;
118 return {
119 ...nextBase,
120 threads: updateThread(nextBase.threads, threadId, {
121 ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}),
122 ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}),
123 ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}),
124 ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}),
125 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
126 }),
127 };
128 }
129 case "message.sent": {
130 if (!payload) return nextBase;
131 const threadId = asString(payload.threadId) ?? event.aggregateId;
132 const message: OrchestrationMessage = {
133 id: asString(payload.id) ?? crypto.randomUUID(),
134 role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user",
135 text: asString(payload.text) ?? "",
136 createdAt: asString(payload.createdAt) ?? event.occurredAt,
137 streaming: payload.streaming === true,
138 };
139 const targetThread = nextBase.threads.find((thread) => thread.id === threadId);
140 if (!targetThread) return nextBase;
141 const existingMessage = targetThread.messages.find((entry) => entry.id === message.id);
142 const nextMessages = existingMessage
143 ? targetThread.messages.map((entry) =>
144 entry.id === message.id
145 ? {
146 ...entry,
147 text:
148 message.streaming
149 ? `${entry.text}${message.text}`
150 : message.text.length > 0
151 ? message.text
152 : entry.text,
153 streaming: message.streaming,
154 createdAt: message.createdAt,
155 }
156 : entry,
157 )
158 : [...targetThread.messages, message];
159 return {
160 ...nextBase,
161 threads: updateThread(nextBase.threads, threadId, {
162 messages: nextMessages,
163 updatedAt: event.occurredAt,
164 }),
165 };
166 }
167 case "thread.session-set": {
168 if (!payload) return nextBase;
169 const threadId = asString(payload.threadId) ?? event.aggregateId;
170 const thread = nextBase.threads.find((entry) => entry.id === threadId);
171 if (!thread) return nextBase;
172 const sessionPayload = asObject(payload.session);
173 if (!sessionPayload) return nextBase;
174 const session: OrchestrationSession = {
175 sessionId: asString(sessionPayload.sessionId) ?? crypto.randomUUID(),
176 status:
177 (asString(sessionPayload.status) as OrchestrationSession["status"] | null) ?? "connecting",
178 provider:
179 (asString(sessionPayload.provider) as OrchestrationSession["provider"] | null) ?? "codex",
180 threadId,
181 activeTurnId: asString(sessionPayload.activeTurnId),
182 createdAt: asString(sessionPayload.createdAt) ?? event.occurredAt,
183 updatedAt: asString(sessionPayload.updatedAt) ?? event.occurredAt,
184 lastError: asString(sessionPayload.lastError),
185 };
186 return {
187 ...nextBase,
188 threads: updateThread(nextBase.threads, threadId, {
189 session,
190 updatedAt: event.occurredAt,
191 error: session.lastError,
192 }),
193 };
194 }
195 case "git.read-model-upsert": {
196 if (!payload) return nextBase;
197 const projectId = asString(payload.projectId) ?? event.aggregateId;
198 const gitReadModel: OrchestrationGitReadModel = {
199 projectId,
200 branch: asString(payload.branch),
201 hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true,
202 aheadCount: asNumber(payload.aheadCount) ?? 0,
203 behindCount: asNumber(payload.behindCount) ?? 0,
204 updatedAt: asString(payload.updatedAt) ?? event.occurredAt,
205 };
206 return {
207 ...nextBase,
208 gitByProjectId: {
209 ...nextBase.gitByProjectId,
210 [projectId]: gitReadModel,
211 },
212 };
213 }
214 default:
215 return nextBase;
216 }
217}
apps/server/src/orchestration/uiContractInventory.ts created+66
......@@ -0,0 +1,66 @@
1export interface UiEntityContract {
2 entity: string;
3 requiredFields: string[];
4 consumedBy: string[];
5}
6
7// Explicit UI inventory used to keep server read models aligned
8// with what the current frontend renders.
9export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [
10 {
11 entity: "Project",
12 requiredFields: ["id", "name", "cwd", "model"],
13 consumedBy: ["apps/web/src/components/Sidebar.tsx"],
14 },
15 {
16 entity: "Thread",
17 requiredFields: [
18 "id",
19 "projectId",
20 "title",
21 "model",
22 "branch",
23 "worktreePath",
24 "createdAt",
25 "latestTurnId",
26 "latestTurnStartedAt",
27 "latestTurnCompletedAt",
28 "latestTurnDurationMs",
29 "error",
30 ],
31 consumedBy: [
32 "apps/web/src/components/Sidebar.tsx",
33 "apps/web/src/components/ChatView.tsx",
34 "apps/web/src/components/BranchToolbar.tsx",
35 ],
36 },
37 {
38 entity: "Message",
39 requiredFields: ["id", "role", "text", "createdAt", "streaming"],
40 consumedBy: ["apps/web/src/components/ChatView.tsx"],
41 },
42 {
43 entity: "ProviderSession",
44 requiredFields: [
45 "sessionId",
46 "provider",
47 "status",
48 "threadId",
49 "activeTurnId",
50 "createdAt",
51 "updatedAt",
52 "lastError",
53 ],
54 consumedBy: ["apps/web/src/components/ChatView.tsx", "apps/web/src/components/Sidebar.tsx"],
55 },
56 {
57 entity: "TurnDiffSummary",
58 requiredFields: ["turnId", "completedAt", "status", "files"],
59 consumedBy: ["apps/web/src/components/DiffPanel.tsx", "apps/web/src/components/ChatView.tsx"],
60 },
61 {
62 entity: "GitReadModel",
63 requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"],
64 consumedBy: ["apps/web/src/components/GitActionsControl.tsx", "apps/web/src/components/BranchToolbar.tsx"],
65 },
66];
apps/server/src/wsServer.test.ts+17-21
......@@ -621,7 +621,8 @@ describe("WebSocket Server", () => {
621621 const ws = await connectWs(port);
622622 connections.push(ws);
623623
624 // Consume welcome
624 // Consume welcome + initial orchestration snapshot pushes
625 await waitForMessage(ws);
625626 await waitForMessage(ws);
626627
627628 const response = await sendRequest(ws, "nonexistent.method");
......@@ -629,23 +630,6 @@ describe("WebSocket Server", () => {
629630 expect(response.error!.message).toContain("Unknown method");
630631 });
631632
632 it("responds to providers.listSessions with empty array", async () => {
633 server = createTestServer({ cwd: "/test" });
634 await server.start();
635 const addr = server.httpServer.address();
636 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
637
638 const ws = await connectWs(port);
639 connections.push(ws);
640
641 // Consume welcome
642 await waitForMessage(ws);
643
644 const response = await sendRequest(ws, WS_METHODS.providersListSessions);
645 expect(response.error).toBeUndefined();
646 expect(response.result).toEqual([]);
647 });
648
649633 it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => {
650634 server = createTestServer({ cwd: "/test" });
651635 await server.start();
......@@ -800,9 +784,21 @@ describe("WebSocket Server", () => {
800784 // Send garbage
801785 ws.send("not json at all");
802786
803 const response = (await waitForMessage(ws)) as WsResponse;
804 expect(response.error).toBeDefined();
805 expect(response.error!.message).toContain("Invalid request format");
787 let response: WsResponse | null = null;
788 for (let attempt = 0; attempt < 5; attempt += 1) {
789 const message = (await waitForMessage(ws)) as Record<string, unknown>;
790 if (typeof message.id === "string" && message.id === "unknown") {
791 response = message as WsResponse;
792 break;
793 }
794 if (message.error) {
795 response = message as WsResponse;
796 break;
797 }
798 }
799 expect(response).toBeDefined();
800 expect(response!.error).toBeDefined();
801 expect(response!.error!.message).toContain("Invalid request format");
806802 });
807803
808804 it("supports projects list/add/dedupe/remove", async () => {
apps/server/src/wsServer.ts+185-12
......@@ -7,8 +7,11 @@ import type { Duplex } from "node:stream";
77
88import {
99 EDITORS,
10 ORCHESTRATION_WS_CHANNELS,
11 ORCHESTRATION_WS_METHODS,
1012 WS_CHANNELS,
1113 WS_METHODS,
14 type OrchestrationReadModelPush,
1215 type TerminalEvent,
1316 type WsPush,
1417 type WsRequest,
......@@ -33,6 +36,7 @@ import {
3336import { TerminalManager } from "./terminalManager";
3437import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings";
3538import { searchWorkspaceEntries } from "./workspaceEntries";
39import { OrchestrationEngine } from "./orchestration/engine";
3640
3741const MIME_TYPES: Record<string, string> = {
3842 ".html": "text/html; charset=utf-8",
......@@ -53,6 +57,7 @@ export interface ServerOptions {
5357 port: number;
5458 host?: string | undefined;
5559 cwd: string;
60 stateDir?: string | undefined;
5661 staticDir?: string | undefined;
5762 devUrl?: string | undefined;
5863 logWebSocketEvents?: boolean | undefined;
......@@ -75,6 +80,7 @@ export function createServer(options: ServerOptions) {
7580 port,
7681 host,
7782 cwd,
83 stateDir,
7884 staticDir,
7985 devUrl,
8086 logWebSocketEvents: explicitLogWsEvents,
......@@ -88,6 +94,9 @@ export function createServer(options: ServerOptions) {
8894 const projectRegistry =
8995 providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata"));
9096 const gitManager = providedGitManager ?? new GitManager();
97 const orchestrationEngine = new OrchestrationEngine(
98 stateDir ?? path.join(os.homedir(), ".t3", "userdata"),
99 );
91100 const clients = new Set<WebSocket>();
92101 const logger = createLogger("ws");
93102 const logWebSocketEvents =
......@@ -103,11 +112,30 @@ export function createServer(options: ServerOptions) {
103112 });
104113 }
105114
106 // Forward provider events to all connected WebSocket clients
107 providerManager.on("event", (event) => {
115 const unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => {
108116 const push: WsPush = {
109117 type: "push",
110 channel: WS_CHANNELS.providerEvent,
118 channel: ORCHESTRATION_WS_CHANNELS.readModel,
119 data: {
120 sequence: snapshot.sequence,
121 snapshot,
122 } satisfies OrchestrationReadModelPush,
123 };
124 const message = JSON.stringify(push);
125 let recipients = 0;
126 for (const client of clients) {
127 if (client.readyState === client.OPEN) {
128 client.send(message);
129 recipients += 1;
130 }
131 }
132 logOutgoingPush(push, recipients);
133 });
134
135 const unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => {
136 const push: WsPush = {
137 type: "push",
138 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
111139 data: event,
112140 };
113141 const message = JSON.stringify(push);
......@@ -121,6 +149,78 @@ export function createServer(options: ServerOptions) {
121149 logOutgoingPush(push, recipients);
122150 });
123151
152 // Forward provider events to all connected WebSocket clients
153 providerManager.on("event", (event) => {
154 void (async () => {
155 const snapshot = orchestrationEngine.getSnapshot();
156 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId);
157 if (!thread) return;
158 const now = event.createdAt;
159 if (event.method === "turn/started" || event.method === "turn/completed") {
160 await orchestrationEngine.dispatch({
161 type: "thread.session",
162 commandId: crypto.randomUUID(),
163 threadId: thread.id,
164 session: {
165 sessionId: event.sessionId,
166 provider: event.provider,
167 status: event.method === "turn/started" ? "running" : "ready",
168 threadId: thread.id,
169 activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null,
170 createdAt: thread.session?.createdAt ?? now,
171 updatedAt: now,
172 lastError: null,
173 },
174 createdAt: now,
175 });
176 }
177 if (event.textDelta && event.textDelta.length > 0) {
178 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;
179 await orchestrationEngine.dispatch({
180 type: "message.send",
181 commandId: crypto.randomUUID(),
182 threadId: thread.id,
183 messageId: assistantMessageId,
184 role: "assistant",
185 text: event.textDelta,
186 streaming: true,
187 createdAt: now,
188 });
189 }
190 if (event.method === "turn/completed") {
191 const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`;
192 await orchestrationEngine.dispatch({
193 type: "message.send",
194 commandId: crypto.randomUUID(),
195 threadId: thread.id,
196 messageId: assistantMessageId,
197 role: "assistant",
198 text: "",
199 streaming: false,
200 createdAt: now,
201 });
202 }
203 if (event.kind === "error" && event.message) {
204 await orchestrationEngine.dispatch({
205 type: "thread.session",
206 commandId: crypto.randomUUID(),
207 threadId: thread.id,
208 session: {
209 sessionId: event.sessionId,
210 provider: event.provider,
211 status: "error",
212 threadId: thread.id,
213 activeTurnId: event.turnId ?? null,
214 createdAt: thread.session?.createdAt ?? now,
215 updatedAt: now,
216 lastError: event.message,
217 },
218 createdAt: now,
219 });
220 }
221 })().catch(() => undefined);
222 });
223
124224 const onTerminalEvent = (event: TerminalEvent) => {
125225 const push: WsPush = {
126226 type: "push",
......@@ -247,6 +347,17 @@ export function createServer(options: ServerOptions) {
247347 logOutgoingPush(welcome, 1);
248348 ws.send(JSON.stringify(welcome));
249349
350 const snapshotPush: WsPush = {
351 type: "push",
352 channel: ORCHESTRATION_WS_CHANNELS.readModel,
353 data: {
354 sequence: orchestrationEngine.getSnapshot().sequence,
355 snapshot: orchestrationEngine.getSnapshot(),
356 } satisfies OrchestrationReadModelPush,
357 };
358 logOutgoingPush(snapshotPush, 1);
359 ws.send(JSON.stringify(snapshotPush));
360
250361 ws.on("message", (raw) => {
251362 void handleMessage(ws, raw);
252363 });
......@@ -307,9 +418,6 @@ export function createServer(options: ServerOptions) {
307418 return undefined;
308419 }
309420
310 case WS_METHODS.providersListSessions:
311 return providerManager.listSessions();
312
313421 case WS_METHODS.providersListCheckpoints:
314422 return providerManager.listCheckpoints(request.params as never);
315423
......@@ -323,11 +431,32 @@ export function createServer(options: ServerOptions) {
323431 return projectRegistry.list();
324432
325433 case WS_METHODS.projectsAdd:
326 return projectRegistry.add(request.params as never);
434 {
435 const result = await projectRegistry.add(request.params as never);
436 await orchestrationEngine.dispatch({
437 type: "project.create",
438 commandId: crypto.randomUUID(),
439 projectId: result.project.id,
440 name: result.project.name,
441 cwd: result.project.cwd,
442 model: "gpt-5-codex",
443 createdAt: new Date().toISOString(),
444 });
445 return result;
446 }
327447
328448 case WS_METHODS.projectsRemove:
329 projectRegistry.remove(request.params as never);
330 return undefined;
449 {
450 const params = request.params as { id: string };
451 projectRegistry.remove(request.params as never);
452 await orchestrationEngine.dispatch({
453 type: "project.delete",
454 commandId: crypto.randomUUID(),
455 projectId: params.id,
456 createdAt: new Date().toISOString(),
457 });
458 return undefined;
459 }
331460
332461 case WS_METHODS.projectsSearchEntries:
333462 return searchWorkspaceEntries(request.params as never);
......@@ -379,7 +508,24 @@ export function createServer(options: ServerOptions) {
379508 }
380509
381510 case WS_METHODS.gitStatus:
382 return gitManager.status(request.params as never);
511 {
512 const params = request.params as { cwd: string };
513 const status = await gitManager.status(request.params as never);
514 const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd);
515 if (project) {
516 await orchestrationEngine.dispatch({
517 type: "git.readModel.upsert",
518 commandId: crypto.randomUUID(),
519 projectId: project.id,
520 branch: status.branch,
521 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
522 aheadCount: status.aheadCount,
523 behindCount: status.behindCount,
524 createdAt: new Date().toISOString(),
525 });
526 }
527 return status;
528 }
383529
384530 case WS_METHODS.gitPull:
385531 return pullGitBranch(request.params as never);
......@@ -438,6 +584,25 @@ export function createServer(options: ServerOptions) {
438584 keybindings: keybindingsConfig,
439585 };
440586
587 case ORCHESTRATION_WS_METHODS.getSnapshot:
588 return orchestrationEngine.getSnapshot();
589
590 case ORCHESTRATION_WS_METHODS.dispatchCommand:
591 return orchestrationEngine.dispatchUnknown(request.params);
592
593 case ORCHESTRATION_WS_METHODS.replayEvents:
594 return orchestrationEngine.replayEvents(
595 Math.max(
596 0,
597 Math.floor(
598 Number(
599 (request.params as { fromSequenceExclusive?: number } | undefined)
600 ?.fromSequenceExclusive ?? 0,
601 ),
602 ),
603 ),
604 );
605
441606 default:
442607 throw new Error(`Unknown method: ${request.method}`);
443608 }
......@@ -450,9 +615,14 @@ export function createServer(options: ServerOptions) {
450615 reject(error);
451616 };
452617 httpServer.once("error", onError);
453 const onListening = () => {
618 const onListening = async () => {
454619 httpServer.off("error", onError);
455 resolve();
620 try {
621 await orchestrationEngine.start();
622 resolve();
623 } catch (error) {
624 reject(error instanceof Error ? error : new Error("Failed to start orchestration engine"));
625 }
456626 };
457627 if (host) {
458628 httpServer.listen(port, host, onListening);
......@@ -463,6 +633,9 @@ export function createServer(options: ServerOptions) {
463633 }
464634
465635 async function stop(): Promise<void> {
636 unsubscribeReadModel();
637 unsubscribeDomainEvents();
638 await orchestrationEngine.stop();
466639 terminalManager.off("event", onTerminalEvent);
467640 providerManager.stopAll();
468641 providerManager.dispose();
apps/web/src/components/BranchToolbar.tsx+21-1
......@@ -92,13 +92,23 @@ export default function BranchToolbar({
9292 });
9393 if (!activeThreadId || !syncedBranch) return;
9494
95 if (api) {
96 void api.orchestration.dispatchCommand({
97 type: "thread.meta.update",
98 commandId: crypto.randomUUID(),
99 threadId: activeThreadId,
100 branch: syncedBranch,
101 worktreePath: null,
102 createdAt: new Date().toISOString(),
103 });
104 }
95105 dispatch({
96106 type: "SET_THREAD_BRANCH",
97107 threadId: activeThreadId,
98108 branch: syncedBranch,
99109 worktreePath: null,
100110 });
101 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]);
111 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch, api]);
102112
103113 useEffect(() => {
104114 if (isBranchMenuOpen) return;
......@@ -120,6 +130,16 @@ export default function BranchToolbar({
120130 if (sessionId && worktreePath !== activeWorktreePath) {
121131 void api?.providers.stopSession({ sessionId }).catch(() => undefined);
122132 }
133 if (api) {
134 void api.orchestration.dispatchCommand({
135 type: "thread.meta.update",
136 commandId: crypto.randomUUID(),
137 threadId: activeThreadId,
138 branch,
139 worktreePath,
140 createdAt: new Date().toISOString(),
141 });
142 }
123143 dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
124144 };
125145
apps/web/src/components/ChatView.tsx+65-113
......@@ -57,7 +57,6 @@ import {
5757 derivePhase,
5858 deriveTurnDiffFilesFromUnifiedDiff,
5959 deriveTimelineEntries,
60 type TurnDiffSummary,
6160 type PendingApproval,
6261 deriveWorkLogEntries,
6362 formatDuration,
......@@ -72,6 +71,7 @@ import {
7271 MAX_THREAD_TERMINAL_COUNT,
7372 type ChatImageAttachment,
7473 type TurnDiffFileChange,
74 type TurnDiffSummary,
7575} from "../types";
7676import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
7777import { useTheme } from "../hooks/useTheme";
......@@ -433,28 +433,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
433433 const phase = derivePhase(activeThread?.session ?? null);
434434 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
435435 const nowIso = new Date(nowTick).toISOString();
436 const workLogEntries = useMemo(
437 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
438 [activeThread?.events],
439 );
436 const workLogEntries = useMemo(() => deriveWorkLogEntries([], undefined), []);
440437 const latestTurnWorkEntries = useMemo(
441 () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId),
442 [activeThread?.events, activeThread?.latestTurnId],
443 );
444 const pendingApprovals = useMemo(
445 () => derivePendingApprovals(activeThread?.events ?? []),
446 [activeThread?.events],
438 () => deriveWorkLogEntries([], activeThread?.latestTurnId),
439 [activeThread?.latestTurnId],
447440 );
448 const assistantCompletionByItemId = useMemo(() => {
449 const map = new Map<string, string>();
450 const ordered = [...(activeThread?.events ?? [])].toReversed();
451 for (const event of ordered) {
452 if (event.method !== "item/completed") continue;
453 if (!event.itemId) continue;
454 map.set(event.itemId, event.createdAt);
455 }
456 return map;
457 }, [activeThread?.events]);
441 const pendingApprovals = useMemo(() => derivePendingApprovals([]), []);
442 const assistantCompletionByItemId = useMemo(() => new Map<string, string>(), []);
458443 const timelineEntries = useMemo(
459444 () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
460445 [activeThread?.messages, workLogEntries],
......@@ -565,36 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
565550 return byUserMessageId;
566551 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
567552
568 useEffect(() => {
569 if (!activeThreadId || turnDiffSummaries.length === 0) {
570 return;
571 }
572
573 const inferredMissingTurnCounts = turnDiffSummaries.reduce<Record<string, number>>(
574 (acc, summary) => {
575 if (typeof summary.checkpointTurnCount === "number") {
576 return acc;
577 }
578 const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId];
579 if (typeof inferredTurnCount !== "number") {
580 return acc;
581 }
582 acc[summary.turnId] = inferredTurnCount;
583 return acc;
584 },
585 {},
586 );
587 if (Object.keys(inferredMissingTurnCounts).length === 0) {
588 return;
589 }
590
591 dispatch({
592 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
593 threadId: activeThreadId,
594 checkpointTurnCountByTurnId: inferredMissingTurnCounts,
595 });
596 }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);
597
598553 const completionSummary = useMemo(() => {
599554 if (!activeThread?.latestTurnStartedAt) return null;
600555 if (!activeThread.latestTurnCompletedAt) return null;
......@@ -1473,28 +1428,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
14731428 checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint);
14741429
14751430 try {
1476 const result = await api.providers.listCheckpoints({
1431 await api.providers.listCheckpoints({
14771432 sessionId: input.sessionId,
14781433 });
1479 const turnIdSet = new Set(turnIds);
1480 const checkpointTurnCountByTurnId = Object.fromEntries(
1481 result.checkpoints
1482 .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id))
1483 .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const),
1484 );
1485 if (Object.keys(checkpointTurnCountByTurnId).length === 0) {
1486 return;
1487 }
1488 dispatch({
1489 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
1490 threadId: input.threadId,
1491 checkpointTurnCountByTurnId,
1492 });
14931434 } catch {
14941435 checkpointTurnCountSyncFingerprintRef.current.delete(syncKey);
14951436 }
14961437 },
1497 [api, dispatch, turnDiffSummaries],
1438 [api, turnDiffSummaries],
14981439 );
14991440
15001441 const ensureSession = useCallback(
......@@ -1533,10 +1474,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
15331474 approvalPolicy: runtimeApprovalPolicy,
15341475 sandboxMode: runtimeSandboxMode,
15351476 });
1536 dispatch({
1537 type: "UPDATE_SESSION",
1477 await api.orchestration.dispatchCommand({
1478 type: "thread.session",
1479 commandId: crypto.randomUUID(),
15381480 threadId: activeThread.id,
1539 session,
1481 session: {
1482 sessionId: session.sessionId,
1483 provider: session.provider,
1484 status: session.status,
1485 threadId: session.threadId ?? activeThread.id,
1486 activeTurnId: session.activeTurnId ?? null,
1487 createdAt: session.createdAt,
1488 updatedAt: session.updatedAt,
1489 lastError: session.lastError ?? null,
1490 },
1491 createdAt: new Date().toISOString(),
15401492 });
15411493 const resolvedThreadId = session.threadId ?? null;
15421494 const continuityState: SessionContinuityState =
......@@ -1572,7 +1524,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
15721524 api,
15731525 codexBinaryPath,
15741526 codexHomePath,
1575 dispatch,
15761527 runtimeApprovalPolicy,
15771528 runtimeSandboxMode,
15781529 selectedModel,
......@@ -1642,27 +1593,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
16421593 if (!sessionInfo) {
16431594 return;
16441595 }
1645 const result = await api.providers.revertToCheckpoint({
1596 await api.providers.revertToCheckpoint({
16461597 sessionId: sessionInfo.sessionId,
16471598 turnCount,
16481599 });
1649 dispatch({
1650 type: "REVERT_TO_CHECKPOINT",
1651 threadId: activeThread.id,
1652 sessionId: sessionInfo.sessionId,
1653 threadRuntimeId: result.threadId,
1654 turnCount: result.turnCount,
1655 messageCount: result.messageCount,
1656 });
1657 dispatch({
1658 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
1659 threadId: activeThread.id,
1660 checkpointTurnCountByTurnId: Object.fromEntries(
1661 result.checkpoints
1662 .filter((entry) => entry.turnCount > 0)
1663 .map((entry) => [entry.id, entry.turnCount] as const),
1664 ),
1665 });
16661600 } catch (err) {
16671601 setThreadError(
16681602 activeThread.id,
......@@ -1709,12 +1643,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
17091643 newBranch,
17101644 });
17111645 sessionCwd = result.worktree.path;
1712 dispatch({
1713 type: "SET_THREAD_BRANCH",
1714 threadId: activeThread.id,
1715 branch: result.worktree.branch,
1716 worktreePath: result.worktree.path,
1717 });
1646 if (api) {
1647 await api.orchestration.dispatchCommand({
1648 type: "thread.meta.update",
1649 commandId: crypto.randomUUID(),
1650 threadId: activeThread.id,
1651 branch: result.worktree.branch,
1652 worktreePath: result.worktree.path,
1653 createdAt: new Date().toISOString(),
1654 });
1655 }
17181656 const setupScript = setupProjectScript(activeProject.scripts);
17191657 if (setupScript) {
17201658 void runProjectScript(setupScript, {
......@@ -1741,11 +1679,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
17411679 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
17421680 : "New thread");
17431681 const title = truncateTitle(titleSeed);
1744 dispatch({
1745 type: "SET_THREAD_TITLE",
1746 threadId: activeThread.id,
1747 title,
1748 });
1682 if (api) {
1683 await api.orchestration.dispatchCommand({
1684 type: "thread.meta.update",
1685 commandId: crypto.randomUUID(),
1686 threadId: activeThread.id,
1687 title,
1688 createdAt: new Date().toISOString(),
1689 });
1690 }
17491691 }
17501692
17511693 setThreadError(activeThread.id, null);
......@@ -1757,13 +1699,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
17571699 sizeBytes: image.sizeBytes,
17581700 previewUrl: image.previewUrl,
17591701 }));
1760 dispatch({
1761 type: "PUSH_USER_MESSAGE",
1762 threadId: activeThread.id,
1763 id: crypto.randomUUID(),
1764 text: trimmed,
1765 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
1766 });
1702 const userMessageId = crypto.randomUUID();
1703 if (api) {
1704 await api.orchestration.dispatchCommand({
1705 type: "message.send",
1706 commandId: crypto.randomUUID(),
1707 threadId: activeThread.id,
1708 messageId: userMessageId,
1709 role: "user",
1710 text: trimmed,
1711 streaming: false,
1712 createdAt: new Date().toISOString(),
1713 });
1714 }
17671715 const previousMessages = activeThread.messages;
17681716 promptRef.current = "";
17691717 setPrompt("");
......@@ -1852,14 +1800,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
18521800 const onModelSelect = useCallback(
18531801 (model: ModelSlug) => {
18541802 if (!activeThread) return;
1855 dispatch({
1856 type: "SET_THREAD_MODEL",
1857 threadId: activeThread.id,
1858 model: resolveModelSlug(model),
1859 });
1803 if (api) {
1804 void api.orchestration.dispatchCommand({
1805 type: "thread.meta.update",
1806 commandId: crypto.randomUUID(),
1807 threadId: activeThread.id,
1808 model: resolveModelSlug(model),
1809 createdAt: new Date().toISOString(),
1810 });
1811 }
18601812 scheduleComposerFocus();
18611813 },
1862 [activeThread, dispatch, scheduleComposerFocus],
1814 [activeThread, api, scheduleComposerFocus],
18631815 );
18641816 const onEffortSelect = useCallback(
18651817 (effort: ReasoningEffort) => {
apps/web/src/components/Sidebar.tsx+51-60
......@@ -7,16 +7,14 @@ import { useAppSettings } from "../appSettings";
77import { isElectron } from "../env";
88import { APP_STAGE_LABEL } from "../branding";
99import { DEFAULT_MODEL } from "../model-logic";
10import { derivePendingApprovals } from "../session-logic";
1110import { useStore } from "../store";
1211import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
13import { type Project, type Thread } from "../types";
12import { type Thread } from "../types";
1413import { useNativeApi } from "../hooks/useNativeApi";
1514import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
1615import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1716import { toastManager } from "./ui/toast";
1817import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
19import { createThread } from "../threadFactory";
2018
2119const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2220
......@@ -131,13 +129,7 @@ export default function Sidebar() {
131129 const [newCwd, setNewCwd] = useState("");
132130 const [isPickingFolder, setIsPickingFolder] = useState(false);
133131 const [isAddingProject, setIsAddingProject] = useState(false);
134 const pendingApprovalByThreadId = useMemo(() => {
135 const map = new Map<string, boolean>();
136 for (const thread of state.threads) {
137 map.set(thread.id, derivePendingApprovals(thread.events).length > 0);
138 }
139 return map;
140 }, [state.threads]);
132 const pendingApprovalByThreadId = useMemo(() => new Map<string, boolean>(), []);
141133
142134 const handleNewThread = useCallback(
143135 (
......@@ -147,21 +139,27 @@ export default function Sidebar() {
147139 worktreePath?: string | null;
148140 },
149141 ) => {
150 const thread = createThread(projectId, {
151 model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL,
142 if (!api) return;
143 const threadId = crypto.randomUUID();
144 const createdAt = new Date().toISOString();
145 const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
146 void api.orchestration.dispatchCommand({
147 type: "thread.create",
148 commandId: crypto.randomUUID(),
149 threadId,
150 projectId,
151 title: "New thread",
152 model,
152153 branch: options?.branch ?? null,
153154 worktreePath: options?.worktreePath ?? null,
154 });
155 dispatch({
156 type: "ADD_THREAD",
157 thread,
155 createdAt,
158156 });
159157 void navigate({
160158 to: "/$threadId",
161 params: { threadId: thread.id },
159 params: { threadId },
162160 });
163161 },
164 [dispatch, navigate, state.projects],
162 [api, dispatch, navigate, state.projects],
165163 );
166164
167165 const focusMostRecentThreadForProject = useCallback(
......@@ -190,47 +188,30 @@ export default function Sidebar() {
190188
191189 setIsAddingProject(true);
192190 try {
193 if (isElectron && api) {
194 const result = await api.projects.add({ cwd });
195 const project: Project = {
196 id: result.project.id,
197 name: result.project.name,
198 cwd: result.project.cwd,
199 model: DEFAULT_MODEL,
200 expanded: true,
201 scripts: result.project.scripts,
202 };
203 const existingById = state.projects.find((p) => p.id === project.id);
204 const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);
205 if (!existingById && !existingByCwd) {
206 dispatch({ type: "ADD_PROJECT", project });
207 }
208 const resolvedProjectId = existingByCwd?.id ?? project.id;
209
210 if (result.created) {
211 handleNewThread(resolvedProjectId);
212 } else {
213 focusMostRecentThreadForProject(resolvedProjectId);
214 }
215 } else {
216 const existing = state.projects.find((project) => project.cwd === cwd);
217 if (existing) {
218 focusMostRecentThreadForProject(existing.id);
219 return;
220 }
221
222 const name = inferProjectName(cwd);
223 const project: Project = {
224 id: crypto.randomUUID(),
225 name,
226 cwd,
227 model: DEFAULT_MODEL,
228 expanded: true,
229 scripts: [],
230 };
231 dispatch({ type: "ADD_PROJECT", project });
232 handleNewThread(project.id);
191 if (!api) return;
192 const existing = state.projects.find((project) => project.cwd === cwd);
193 if (existing) {
194 focusMostRecentThreadForProject(existing.id);
195 return;
233196 }
197
198 const projectId = crypto.randomUUID();
199 const now = new Date().toISOString();
200 const name = inferProjectName(cwd);
201 if (isElectron) {
202 // Keep project registry in sync for desktop integrations.
203 await api.projects.add({ cwd });
204 }
205 await api.orchestration.dispatchCommand({
206 type: "project.create",
207 commandId: crypto.randomUUID(),
208 projectId,
209 name,
210 cwd,
211 model: DEFAULT_MODEL,
212 createdAt: now,
213 });
214 handleNewThread(projectId);
234215 } finally {
235216 setIsAddingProject(false);
236217 setNewCwd("");
......@@ -321,7 +302,12 @@ export default function Sidebar() {
321302
322303 const shouldNavigateToFallback = routeThreadId === threadId;
323304 const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;
324 dispatch({ type: "DELETE_THREAD", threadId });
305 await api.orchestration.dispatchCommand({
306 type: "thread.delete",
307 commandId: crypto.randomUUID(),
308 threadId,
309 createdAt: new Date().toISOString(),
310 });
325311 if (shouldNavigateToFallback) {
326312 if (fallbackThreadId) {
327313 void navigate({
......@@ -411,7 +397,12 @@ export default function Sidebar() {
411397 }
412398 }
413399
414 dispatch({ type: "DELETE_PROJECT", projectId });
400 await api.orchestration.dispatchCommand({
401 type: "project.delete",
402 commandId: crypto.randomUUID(),
403 projectId,
404 createdAt: new Date().toISOString(),
405 });
415406 },
416407 [api, dispatch, state.projects, state.threads],
417408 );
apps/web/src/hooks/useNativeApi.ts+1-1
......@@ -1,5 +1,5 @@
11import { useMemo } from "react";
2import { readNativeApi } from "../session-logic";
2import { readNativeApi } from "../nativeApi";
33
44export function useNativeApi() {
55 return useMemo(() => readNativeApi(), []);
apps/web/src/hooks/useTurnDiffSummaries.ts+2-18
......@@ -1,29 +1,13 @@
1import type { ProviderEvent } from "@t3tools/contracts";
21import { useMemo } from "react";
3import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic";
2import { inferCheckpointTurnCountByTurnId } from "../session-logic";
43import type { Thread, TurnDiffSummary } from "../types";
54
6const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>();
7
8function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] {
9 const cached = derivedTurnDiffSummaryCache.get(events);
10 if (cached) {
11 return cached;
12 }
13 const derived = deriveTurnDiffSummaries(events);
14 derivedTurnDiffSummaryCache.set(events, derived);
15 return derived;
16}
17
185export function useTurnDiffSummaries(activeThread: Thread | undefined) {
196 const turnDiffSummaries = useMemo(() => {
207 if (!activeThread) {
218 return [];
229 }
23 if (activeThread.turnDiffSummaries.length > 0) {
24 return activeThread.turnDiffSummaries;
25 }
26 return deriveTurnDiffSummariesCached(activeThread.events);
10 return activeThread.turnDiffSummaries;
2711 }, [activeThread]);
2812
2913 const inferredCheckpointTurnCountByTurnId = useMemo(
apps/web/src/nativeApi.ts created+18
......@@ -0,0 +1,18 @@
1import type { NativeApi } from "@t3tools/contracts";
2
3import { createWsNativeApi } from "./wsNativeApi";
4
5let cachedApi: NativeApi | undefined;
6
7export function readNativeApi(): NativeApi | undefined {
8 if (typeof window === "undefined") return undefined;
9 if (cachedApi) return cachedApi;
10
11 if (window.nativeApi) {
12 cachedApi = window.nativeApi;
13 return cachedApi;
14 }
15
16 cachedApi = createWsNativeApi();
17 return cachedApi;
18}
apps/web/src/persistenceSchema.test.ts deleted-507
......@@ -1,507 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
6import type { Thread } from "./types";
7
8describe("hydratePersistedState", () => {
9 it("returns null for invalid payloads", () => {
10 expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull();
11 expect(hydratePersistedState("not-json", false)).toBeNull();
12 });
13
14 it("migrates the legacy default model to the current default", () => {
15 const payload = JSON.stringify({
16 projects: [
17 {
18 id: "p-1",
19 name: "Project",
20 cwd: "/tmp/project",
21 model: "gpt-5.2-codex",
22 expanded: true,
23 scripts: [],
24 },
25 ],
26 threads: [
27 {
28 id: "t-1",
29 projectId: "p-1",
30 title: "Thread",
31 model: "gpt-5.2-codex",
32 messages: [
33 {
34 id: "m-1",
35 role: "assistant",
36 text: "Hello",
37 createdAt: "2026-02-08T10:00:00.000Z",
38 streaming: true,
39 },
40 ],
41 createdAt: "2026-02-08T10:00:00.000Z",
42 },
43 ],
44 activeThreadId: "t-1",
45 });
46
47 const hydrated = hydratePersistedState(payload, true);
48 expect(hydrated).not.toBeNull();
49 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
50 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
51 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
52 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
53 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
54 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
55 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
56 expect(hydrated?.threads[0]?.terminalGroups).toEqual([
57 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
58 ]);
59 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
60 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
61 expect(hydrated?.runtimeMode).toBe("full-access");
62 });
63
64 it("filters unknown project references and repairs active thread", () => {
65 const payload = JSON.stringify({
66 projects: [
67 {
68 id: "p-1",
69 name: "Project",
70 cwd: "/tmp/project",
71 model: "gpt-5.3-codex",
72 expanded: false,
73 scripts: [],
74 },
75 ],
76 threads: [
77 {
78 id: "t-1",
79 projectId: "p-1",
80 title: "Valid thread",
81 model: "gpt-5.3-codex",
82 messages: [],
83 createdAt: "2026-02-08T10:00:00.000Z",
84 },
85 {
86 id: "t-2",
87 projectId: "p-missing",
88 title: "Dangling thread",
89 model: "gpt-5.3-codex",
90 messages: [],
91 createdAt: "2026-02-08T10:00:00.000Z",
92 },
93 ],
94 activeThreadId: "t-2",
95 });
96
97 const hydrated = hydratePersistedState(payload, false);
98 expect(hydrated).not.toBeNull();
99 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
100 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
101 expect(hydrated?.threads[0]?.terminalOpen).toBe(false);
102 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
103 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
104 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
105 expect(hydrated?.threads[0]?.terminalGroups).toEqual([
106 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
107 ]);
108 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
109 expect(hydrated?.runtimeMode).toBe("full-access");
110 });
111
112 it("hydrates runtime mode from v3 payload", () => {
113 const payload = JSON.stringify({
114 version: 3,
115 runtimeMode: "approval-required",
116 projects: [
117 {
118 id: "p-1",
119 name: "Project",
120 cwd: "/tmp/project",
121 model: "gpt-5.3-codex",
122 expanded: false,
123 scripts: [],
124 },
125 ],
126 threads: [],
127 activeThreadId: null,
128 });
129
130 const hydrated = hydratePersistedState(payload, false);
131 expect(hydrated?.runtimeMode).toBe("approval-required");
132 });
133
134 it("hydrates terminal fields from legacy v6 payload", () => {
135 const payload = JSON.stringify({
136 version: 6,
137 runtimeMode: "full-access",
138 projects: [
139 {
140 id: "p-1",
141 name: "Project",
142 cwd: "/tmp/project",
143 model: "gpt-5.3-codex",
144 expanded: true,
145 scripts: [],
146 },
147 ],
148 threads: [
149 {
150 id: "t-1",
151 codexThreadId: null,
152 projectId: "p-1",
153 title: "Thread",
154 model: "gpt-5.3-codex",
155 terminalOpen: true,
156 terminalHeight: 360,
157 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
158 activeTerminalId: "term-2",
159 terminalLayout: "tabs",
160 splitTerminalIds: [],
161 messages: [],
162 createdAt: "2026-02-08T10:00:00.000Z",
163 lastVisitedAt: "2026-02-08T10:01:00.000Z",
164 },
165 ],
166 activeThreadId: "t-1",
167 });
168
169 const hydrated = hydratePersistedState(payload, false);
170 expect(hydrated?.threads[0]?.terminalOpen).toBe(true);
171 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);
172 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
173 expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2");
174 expect(hydrated?.threads[0]?.terminalGroups).toEqual([
175 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
176 { id: "group-term-2", terminalIds: ["term-2"] },
177 ]);
178 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2");
179 expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z");
180 });
181
182 it("hydrates legacy split layout into a grouped split", () => {
183 const payload = JSON.stringify({
184 version: 6,
185 runtimeMode: "full-access",
186 projects: [
187 {
188 id: "p-1",
189 name: "Project",
190 cwd: "/tmp/project",
191 model: "gpt-5.3-codex",
192 expanded: true,
193 scripts: [],
194 },
195 ],
196 threads: [
197 {
198 id: "t-1",
199 codexThreadId: null,
200 projectId: "p-1",
201 title: "Thread",
202 model: "gpt-5.3-codex",
203 terminalOpen: true,
204 terminalHeight: 360,
205 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
206 activeTerminalId: "term-2",
207 terminalLayout: "split",
208 splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
209 messages: [],
210 createdAt: "2026-02-08T10:00:00.000Z",
211 },
212 ],
213 activeThreadId: "t-1",
214 });
215
216 const hydrated = hydratePersistedState(payload, false);
217 expect(hydrated?.threads[0]?.terminalGroups).toEqual([
218 {
219 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
220 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
221 },
222 { id: "group-term-3", terminalIds: ["term-3"] },
223 ]);
224 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
225 });
226
227 it("defaults terminalHeight when hydrating v5 payloads", () => {
228 const payload = JSON.stringify({
229 version: 5,
230 runtimeMode: "full-access",
231 projects: [
232 {
233 id: "p-1",
234 name: "Project",
235 cwd: "/tmp/project",
236 model: "gpt-5.3-codex",
237 expanded: true,
238 scripts: [],
239 },
240 ],
241 threads: [
242 {
243 id: "t-1",
244 codexThreadId: null,
245 projectId: "p-1",
246 title: "Thread",
247 model: "gpt-5.3-codex",
248 terminalOpen: true,
249 messages: [],
250 createdAt: "2026-02-08T10:00:00.000Z",
251 },
252 ],
253 activeThreadId: "t-1",
254 });
255
256 const hydrated = hydratePersistedState(payload, false);
257 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
258 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
259 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
260 expect(hydrated?.threads[0]?.terminalGroups).toEqual([
261 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
262 ]);
263 expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
264 });
265
266 it("hydrates persisted turn diff summaries as metadata-only entries", () => {
267 const payload = JSON.stringify({
268 version: 7,
269 runtimeMode: "full-access",
270 projects: [
271 {
272 id: "p-1",
273 name: "Project",
274 cwd: "/tmp/project",
275 model: "gpt-5.3-codex",
276 expanded: true,
277 },
278 ],
279 threads: [
280 {
281 id: "t-1",
282 codexThreadId: "thr_1",
283 projectId: "p-1",
284 title: "Thread",
285 model: "gpt-5.3-codex",
286 messages: [],
287 createdAt: "2026-02-08T10:00:00.000Z",
288 turnDiffSummaries: [
289 {
290 turnId: "turn-1",
291 completedAt: "2026-02-08T10:05:00.000Z",
292 checkpointTurnCount: 1,
293 files: [
294 {
295 path: "src/app.ts",
296 kind: "modified",
297 additions: 3,
298 deletions: 1,
299 },
300 ],
301 },
302 ],
303 },
304 ],
305 activeThreadId: "t-1",
306 });
307
308 const hydrated = hydratePersistedState(payload, false);
309 expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([
310 {
311 turnId: "turn-1",
312 completedAt: "2026-02-08T10:05:00.000Z",
313 checkpointTurnCount: 1,
314 files: [
315 {
316 path: "src/app.ts",
317 kind: "modified",
318 additions: 3,
319 deletions: 1,
320 },
321 ],
322 },
323 ]);
324 });
325
326 it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => {
327 const payload = JSON.stringify({
328 version: 7,
329 runtimeMode: "full-access",
330 projects: [
331 {
332 id: "p-1",
333 name: "Project",
334 cwd: "/tmp/project",
335 model: "gpt-5.3-codex",
336 expanded: true,
337 },
338 ],
339 threads: [
340 {
341 id: "t-1",
342 codexThreadId: "thr_1",
343 projectId: "p-1",
344 title: "Thread",
345 model: "gpt-5.3-codex",
346 messages: [],
347 createdAt: "2026-02-08T10:00:00.000Z",
348 turnDiffSummaries: [
349 {
350 turnId: "turn-1",
351 completedAt: "2026-02-08T10:05:00.000Z",
352 files: [{ path: 123 }],
353 },
354 ],
355 },
356 ],
357 activeThreadId: "t-1",
358 });
359
360 const hydrated = hydratePersistedState(payload, false);
361 expect(hydrated).not.toBeNull();
362 expect(hydrated?.threads[0]?.id).toBe("t-1");
363 expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]);
364 });
365});
366
367describe("toPersistedState", () => {
368 it("writes v7 payload and strips non-persisted thread fields", () => {
369 const thread: Thread = {
370 id: "t-1",
371 codexThreadId: "thr_1",
372 projectId: "p-1",
373 title: "Thread",
374 model: "gpt-5.3-codex",
375 terminalOpen: true,
376 terminalHeight: 320,
377 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
378 runningTerminalIds: [],
379 activeTerminalId: "term-2",
380 terminalGroups: [
381 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
382 { id: "group-term-2", terminalIds: ["term-2"] },
383 ],
384 activeTerminalGroupId: "group-term-2",
385 session: null,
386 messages: [
387 {
388 id: "m-1",
389 role: "user",
390 text: "Hi",
391 attachments: [
392 {
393 type: "image",
394 id: "img-1",
395 name: "diagram.png",
396 mimeType: "image/png",
397 sizeBytes: 4_096,
398 previewUrl: "blob:preview-1",
399 },
400 ],
401 createdAt: "2026-02-08T10:00:00.000Z",
402 streaming: false,
403 },
404 ],
405 events: [],
406 error: "boom",
407 createdAt: "2026-02-08T10:00:00.000Z",
408 lastVisitedAt: "2026-02-08T10:02:00.000Z",
409 branch: null,
410 worktreePath: null,
411 turnDiffSummaries: [
412 {
413 turnId: "turn-1",
414 completedAt: "2026-02-08T10:05:00.000Z",
415 status: "completed",
416 checkpointTurnCount: 1,
417 files: [
418 {
419 path: "src/app.ts",
420 kind: "modified",
421 additions: 3,
422 deletions: 1,
423 },
424 ],
425 },
426 ],
427 };
428
429 const persisted = toPersistedState({
430 projects: [
431 {
432 id: "p-1",
433 name: "Project",
434 cwd: "/tmp/project",
435 model: "gpt-5.3-codex",
436 expanded: true,
437 scripts: [],
438 },
439 ],
440 threads: [thread],
441 runtimeMode: "full-access",
442 });
443
444 expect(persisted.version).toBe(7);
445 expect(persisted.runtimeMode).toBe("full-access");
446 expect(persisted.threads[0]).toEqual({
447 id: "t-1",
448 codexThreadId: "thr_1",
449 projectId: "p-1",
450 title: "Thread",
451 model: "gpt-5.3-codex",
452 terminalOpen: true,
453 terminalHeight: 320,
454 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
455 activeTerminalId: "term-2",
456 terminalGroups: [
457 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
458 { id: "group-term-2", terminalIds: ["term-2"] },
459 ],
460 activeTerminalGroupId: "group-term-2",
461 messages: [
462 {
463 id: "m-1",
464 role: "user",
465 text: "Hi",
466 attachments: [
467 {
468 type: "image",
469 id: "img-1",
470 name: "diagram.png",
471 mimeType: "image/png",
472 sizeBytes: 4_096,
473 },
474 ],
475 createdAt: "2026-02-08T10:00:00.000Z",
476 streaming: false,
477 },
478 ],
479 createdAt: thread.createdAt,
480 lastVisitedAt: thread.lastVisitedAt,
481 branch: null,
482 worktreePath: null,
483 turnDiffSummaries: [
484 {
485 turnId: "turn-1",
486 completedAt: "2026-02-08T10:05:00.000Z",
487 status: "completed",
488 checkpointTurnCount: 1,
489 files: [
490 {
491 path: "src/app.ts",
492 kind: "modified",
493 additions: 3,
494 deletions: 1,
495 },
496 ],
497 },
498 ],
499 });
500 const persistedThread = persisted.threads[0];
501 expect(persistedThread).toBeDefined();
502 if (!persistedThread) return;
503
504 expect("error" in persistedThread).toBe(false);
505 expect("session" in persistedThread).toBe(false);
506 });
507});
apps/web/src/persistenceSchema.ts deleted-399
......@@ -1,399 +0,0 @@
1import { z } from "zod";
2import { projectScriptSchema } from "@t3tools/contracts";
3
4import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
5import {
6 DEFAULT_THREAD_TERMINAL_HEIGHT,
7 DEFAULT_THREAD_TERMINAL_ID,
8 DEFAULT_RUNTIME_MODE,
9 type Project,
10 type RuntimeMode,
11 type Thread,
12 type ThreadTerminalGroup,
13} from "./types";
14
15const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
16
17const persistedProjectSchema = z.object({
18 id: z.string().min(1),
19 name: z.string().min(1),
20 cwd: z.string().min(1),
21 model: z.string().min(1),
22 expanded: z.boolean(),
23 scripts: z.array(projectScriptSchema).default([]),
24});
25
26const persistedMessageSchema = z.object({
27 id: z.string().min(1),
28 role: z.enum(["user", "assistant"]),
29 text: z.string(),
30 attachments: z
31 .array(
32 z.object({
33 type: z.literal("image"),
34 id: z.string().min(1),
35 name: z.string().min(1),
36 mimeType: z.string().min(1),
37 sizeBytes: z.number().int().min(1),
38 }),
39 )
40 .optional(),
41 createdAt: z.string().min(1),
42 streaming: z.boolean(),
43});
44
45const persistedTerminalGroupSchema = z.object({
46 id: z.string().trim().min(1),
47 terminalIds: z.array(z.string().trim().min(1)),
48});
49
50const persistedTurnDiffFileChangeSchema = z.object({
51 path: z.string().min(1),
52 kind: z.string().min(1).optional(),
53 additions: z.number().int().min(0).optional(),
54 deletions: z.number().int().min(0).optional(),
55});
56
57const persistedTurnDiffSummarySchema = z.object({
58 turnId: z.string().min(1),
59 completedAt: z.string().min(1),
60 status: z.string().min(1).optional(),
61 files: z.array(persistedTurnDiffFileChangeSchema),
62 assistantMessageId: z.string().min(1).optional(),
63 checkpointTurnCount: z.number().int().min(0).optional(),
64});
65
66const persistedThreadSchema = z.object({
67 id: z.string().min(1),
68 codexThreadId: z.string().min(1).nullable().default(null),
69 projectId: z.string().min(1),
70 title: z.string().min(1),
71 model: z.string().min(1),
72 terminalOpen: z.boolean().default(false),
73 terminalHeight: z.number().int().min(120).max(4_096).default(DEFAULT_THREAD_TERMINAL_HEIGHT),
74 terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]),
75 activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID),
76 terminalGroups: z.array(persistedTerminalGroupSchema).default([]),
77 activeTerminalGroupId: z.string().trim().min(1).optional(),
78 // Legacy v6 and older fields retained for migration.
79 terminalLayout: z.enum(["single", "split", "tabs"]).optional(),
80 splitTerminalIds: z.array(z.string().trim().min(1)).optional(),
81 messages: z.array(persistedMessageSchema),
82 createdAt: z.string().min(1),
83 lastVisitedAt: z.string().min(1).optional(),
84 branch: z.string().min(1).nullable().optional(),
85 worktreePath: z.string().min(1).nullable().optional(),
86 turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]),
87});
88
89const persistedStateBodySchema = z.object({
90 projects: z.array(persistedProjectSchema),
91 threads: z.array(persistedThreadSchema),
92 activeThreadId: z.string().min(1).nullable().optional(),
93});
94
95const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
96
97export const persistedStateV1Schema = persistedStateBodySchema.extend({
98 version: z.literal(1).optional(),
99});
100
101export const persistedStateV2Schema = persistedStateBodySchema.extend({
102 version: z.literal(2).optional(),
103});
104
105export const persistedStateV3Schema = persistedStateBodySchema.extend({
106 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
107 version: z.literal(3).optional(),
108});
109
110export const persistedStateV4Schema = persistedStateBodySchema.extend({
111 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
112 version: z.literal(4).optional(),
113});
114
115export const persistedStateV6Schema = persistedStateBodySchema.extend({
116 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
117 version: z.literal(6).optional(),
118});
119
120export const persistedStateV7Schema = persistedStateBodySchema.extend({
121 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
122 version: z.literal(7).optional(),
123});
124
125export const persistedStateV5Schema = persistedStateBodySchema.extend({
126 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
127 version: z.literal(5).optional(),
128});
129
130const persistedStateSchema = z.union([
131 persistedStateV7Schema,
132 persistedStateV6Schema,
133 persistedStateV5Schema,
134 persistedStateV4Schema,
135 persistedStateV3Schema,
136 persistedStateV2Schema,
137 persistedStateV1Schema,
138]);
139
140export interface PersistedStoreSnapshot {
141 projects: Project[];
142 threads: Thread[];
143 runtimeMode: RuntimeMode;
144}
145
146function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string {
147 if (!isLegacyPayload) {
148 return model;
149 }
150
151 return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
152}
153
154function hydrateProject(
155 project: z.infer<typeof persistedProjectSchema>,
156 isLegacyPayload: boolean,
157): Project {
158 return {
159 ...project,
160 model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
161 scripts: project.scripts,
162 };
163}
164
165function hydrateThread(
166 thread: z.infer<typeof persistedThreadSchema>,
167 isLegacyPayload: boolean,
168): Thread {
169 const terminalIds = [
170 ...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)),
171 ];
172 const safeTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
173 const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId)
174 ? thread.activeTerminalId
175 : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
176 const safeTerminalIdSet = new Set(safeTerminalIds);
177 const assignedTerminalIds = new Set<string>();
178 const usedGroupIds = new Set<string>();
179 const normalizedGroups: ThreadTerminalGroup[] = [];
180 const assignUniqueGroupId = (groupId: string): string => {
181 if (!usedGroupIds.has(groupId)) {
182 usedGroupIds.add(groupId);
183 return groupId;
184 }
185 let suffix = 2;
186 while (usedGroupIds.has(`${groupId}-${suffix}`)) {
187 suffix += 1;
188 }
189 const uniqueGroupId = `${groupId}-${suffix}`;
190 usedGroupIds.add(uniqueGroupId);
191 return uniqueGroupId;
192 };
193
194 for (const terminalGroup of thread.terminalGroups) {
195 const nextTerminalIds = [
196 ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)),
197 ].filter((terminalId) => {
198 if (!safeTerminalIdSet.has(terminalId)) return false;
199 if (assignedTerminalIds.has(terminalId)) return false;
200 return true;
201 });
202 if (nextTerminalIds.length === 0) continue;
203 for (const terminalId of nextTerminalIds) {
204 assignedTerminalIds.add(terminalId);
205 }
206 const baseGroupId =
207 terminalGroup.id.trim().length > 0
208 ? terminalGroup.id.trim()
209 : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`;
210 normalizedGroups.push({
211 id: assignUniqueGroupId(baseGroupId),
212 terminalIds: nextTerminalIds,
213 });
214 }
215
216 if (normalizedGroups.length === 0 && thread.terminalLayout === "split") {
217 const splitTerminalIds = [
218 ...new Set(
219 (thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0),
220 ),
221 ].filter((terminalId) => safeTerminalIdSet.has(terminalId));
222 if (splitTerminalIds.length >= 2) {
223 const splitGroupTerminalIds = splitTerminalIds.slice(0, 2);
224 for (const terminalId of splitGroupTerminalIds) {
225 assignedTerminalIds.add(terminalId);
226 }
227 normalizedGroups.push({
228 id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`),
229 terminalIds: splitGroupTerminalIds,
230 });
231 }
232 }
233
234 for (const terminalId of safeTerminalIds) {
235 if (assignedTerminalIds.has(terminalId)) continue;
236 normalizedGroups.push({
237 id: assignUniqueGroupId(`group-${terminalId}`),
238 terminalIds: [terminalId],
239 });
240 }
241
242 const activeGroupIndexFromId = normalizedGroups.findIndex(
243 (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId,
244 );
245 const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) =>
246 terminalGroup.terminalIds.includes(activeTerminalId),
247 );
248 const activeGroupIndex =
249 activeGroupIndexFromId >= 0
250 ? activeGroupIndexFromId
251 : activeGroupIndexFromTerminal >= 0
252 ? activeGroupIndexFromTerminal
253 : 0;
254 const activeTerminalGroupId =
255 normalizedGroups[activeGroupIndex]?.id ??
256 normalizedGroups[0]?.id ??
257 `group-${DEFAULT_THREAD_TERMINAL_ID}`;
258
259 return {
260 id: thread.id,
261 codexThreadId: thread.codexThreadId,
262 projectId: thread.projectId,
263 title: thread.title,
264 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
265 terminalOpen: thread.terminalOpen ?? false,
266 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
267 terminalIds: safeTerminalIds,
268 runningTerminalIds: [],
269 activeTerminalId,
270 terminalGroups: normalizedGroups,
271 activeTerminalGroupId,
272 session: null,
273 messages: thread.messages.map((message) => {
274 const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));
275 return {
276 id: message.id,
277 role: message.role,
278 text: message.text,
279 ...(hydratedAttachments && hydratedAttachments.length > 0
280 ? { attachments: hydratedAttachments }
281 : {}),
282 createdAt: message.createdAt,
283 streaming: false,
284 };
285 }),
286 events: [],
287 error: null,
288 createdAt: thread.createdAt,
289 lastVisitedAt: thread.lastVisitedAt,
290 branch: thread.branch ?? null,
291 worktreePath: thread.worktreePath ?? null,
292 turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
293 turnId: summary.turnId,
294 completedAt: summary.completedAt,
295 ...(summary.status ? { status: summary.status } : {}),
296 files: summary.files.map((file) => ({
297 path: file.path,
298 ...(file.kind ? { kind: file.kind } : {}),
299 ...(typeof file.additions === "number" ? { additions: file.additions } : {}),
300 ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}),
301 })),
302 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
303 ...(typeof summary.checkpointTurnCount === "number"
304 ? { checkpointTurnCount: summary.checkpointTurnCount }
305 : {}),
306 })),
307 };
308}
309
310export function hydratePersistedState(
311 raw: string,
312 isLegacyPayload: boolean,
313): PersistedStoreSnapshot | null {
314 let parsedJson: unknown;
315 try {
316 parsedJson = JSON.parse(raw);
317 } catch {
318 return null;
319 }
320
321 const parsedState = persistedStateSchema.safeParse(parsedJson);
322 if (!parsedState.success) {
323 return null;
324 }
325
326 const projects = parsedState.data.projects.map((project) =>
327 hydrateProject(project, isLegacyPayload),
328 );
329 const projectIds = new Set(projects.map((project) => project.id));
330 const threads = parsedState.data.threads
331 .map((thread) => hydrateThread(thread, isLegacyPayload))
332 .filter((thread) => projectIds.has(thread.projectId));
333 return {
334 projects,
335 threads,
336 runtimeMode:
337 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
338 };
339}
340
341export function toPersistedState(
342 state: PersistedStoreSnapshot,
343): z.infer<typeof persistedStateV7Schema> {
344 return {
345 version: 7,
346 projects: state.projects,
347 threads: state.threads.map((thread) => ({
348 id: thread.id,
349 codexThreadId: thread.codexThreadId,
350 projectId: thread.projectId,
351 title: thread.title,
352 model: thread.model,
353 terminalOpen: thread.terminalOpen,
354 terminalHeight: thread.terminalHeight,
355 terminalIds: thread.terminalIds,
356 activeTerminalId: thread.activeTerminalId,
357 terminalGroups: thread.terminalGroups,
358 activeTerminalGroupId: thread.activeTerminalGroupId,
359 messages: thread.messages.map((message) => ({
360 id: message.id,
361 role: message.role,
362 text: message.text,
363 ...(message.attachments && message.attachments.length > 0
364 ? {
365 attachments: message.attachments.map((attachment) => ({
366 type: attachment.type,
367 id: attachment.id,
368 name: attachment.name,
369 mimeType: attachment.mimeType,
370 sizeBytes: attachment.sizeBytes,
371 })),
372 }
373 : {}),
374 createdAt: message.createdAt,
375 streaming: message.streaming,
376 })),
377 createdAt: thread.createdAt,
378 ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}),
379 branch: thread.branch,
380 worktreePath: thread.worktreePath,
381 turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
382 turnId: summary.turnId,
383 completedAt: summary.completedAt,
384 ...(summary.status ? { status: summary.status } : {}),
385 files: summary.files.map((file) => ({
386 path: file.path,
387 ...(file.kind ? { kind: file.kind } : {}),
388 ...(typeof file.additions === "number" ? { additions: file.additions } : {}),
389 ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}),
390 })),
391 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
392 ...(typeof summary.checkpointTurnCount === "number"
393 ? { checkpointTurnCount: summary.checkpointTurnCount }
394 : {}),
395 })),
396 })),
397 runtimeMode: state.runtimeMode,
398 };
399}
apps/web/src/routes/__root.tsx+39-110
......@@ -2,17 +2,15 @@ import {
22 Outlet,
33 createRootRouteWithContext,
44 type ErrorComponentProps,
5 useParams,
65} from "@tanstack/react-router";
76import { useEffect, useRef } from "react";
8import { QueryClient, useQueryClient } from "@tanstack/react-query";
7import { QueryClient } from "@tanstack/react-query";
98
109import { APP_DISPLAY_NAME } from "../branding";
1110import { Button } from "../components/ui/button";
1211import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
1312import { isElectron } from "../env";
1413import { useNativeApi } from "../hooks/useNativeApi";
15import { invalidateGitQueries } from "../lib/gitReactQuery";
1614import { DEFAULT_MODEL } from "../model-logic";
1715import { useStore } from "../store";
1816import { onServerWelcome } from "../wsNativeApi";
......@@ -125,67 +123,33 @@ function errorDetails(error: unknown): string {
125123function EventRouter() {
126124 const api = useNativeApi();
127125 const { dispatch } = useStore();
128 const queryClient = useQueryClient();
129 const activeAssistantItemRef = useRef<string | null>(null);
130 const activeThreadId = useParams({
131 strict: false,
132 select: (params) => params.threadId,
133 });
134126
135127 useEffect(() => {
136128 if (!api) return;
137 return api.providers.onEvent((event) => {
138 if (event.method === "turn/completed") {
139 void invalidateGitQueries(queryClient);
140 }
141 if (event.method === "checkpoint/captured") {
142 const payload = event.payload as { turnCount?: number } | undefined;
143 const turnCount = payload?.turnCount;
144 void queryClient.invalidateQueries({
145 queryKey: ["providers", "checkpointDiff"] as const,
146 predicate: (query) => {
147 if (typeof turnCount !== "number") return true;
148 return query.queryKey[5] === turnCount;
149 },
150 });
151 }
152 if (!activeThreadId) return;
153 dispatch({
154 type: "APPLY_EVENT",
155 event,
156 activeAssistantItemRef,
157 activeThreadId,
158 });
159 });
160 }, [activeThreadId, api, dispatch, queryClient]);
161
162 useEffect(() => {
163 if (!activeThreadId) return;
164 dispatch({
165 type: "MARK_THREAD_VISITED",
166 threadId: activeThreadId,
167 visitedAt: new Date().toISOString(),
168 });
169 }, [activeThreadId, dispatch]);
170
171 useEffect(() => {
172 if (!api) return;
173 return api.terminal.onEvent((event) => {
174 dispatch({
175 type: "APPLY_TERMINAL_EVENT",
176 event,
177 });
129 void api.orchestration
130 .getSnapshot()
131 .then((snapshot) => {
132 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
133 })
134 .catch(() => undefined);
135 const unsubReadModel = api.orchestration.onReadModel((snapshot) => {
136 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
178137 });
138 return () => {
139 unsubReadModel();
140 };
179141 }, [api, dispatch]);
180142
181143 return null;
182144}
183145
184146function AutoProjectBootstrap() {
147 const api = useNativeApi();
185148 const { state, dispatch } = useStore();
186149 const bootstrappedRef = useRef(false);
187150
188151 useEffect(() => {
152 if (!api) return;
189153 // Browser mode bootstraps from server welcome.
190154 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
191155 if (isElectron) return;
......@@ -202,75 +166,40 @@ function AutoProjectBootstrap() {
202166 }
203167
204168 bootstrappedRef.current = true;
205
206 // Create project + thread from server cwd
207169 const projectId = crypto.randomUUID();
208 dispatch({
209 type: "ADD_PROJECT",
210 project: {
211 id: projectId,
170 const threadId = crypto.randomUUID();
171 const now = new Date().toISOString();
172 void api.orchestration
173 .dispatchCommand({
174 type: "project.create",
175 commandId: crypto.randomUUID(),
176 projectId,
212177 name: payload.projectName,
213178 cwd: payload.cwd,
214179 model: DEFAULT_MODEL,
215 expanded: true,
216 scripts: [],
217 },
218 });
219 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
180 createdAt: now,
181 })
182 .then(() =>
183 api.orchestration.dispatchCommand({
184 type: "thread.create",
185 commandId: crypto.randomUUID(),
186 threadId,
187 projectId,
188 title: "New thread",
189 model: DEFAULT_MODEL,
190 branch: null,
191 worktreePath: null,
192 createdAt: now,
193 }),
194 )
195 .catch(() => undefined);
220196 });
221 }, [state.projects, dispatch]);
197 }, [api, state.projects, dispatch]);
222198
223199 return null;
224200}
225201
226202function DesktopProjectBootstrap() {
227 const api = useNativeApi();
228 const { dispatch } = useStore();
229 const bootstrappedRef = useRef(false);
230
231 useEffect(() => {
232 if (!isElectron || !api || bootstrappedRef.current) return;
233
234 let disposed = false;
235 let retryDelayMs = 500;
236 let retryTimer: ReturnType<typeof setTimeout> | null = null;
237
238 const attemptBootstrap = async () => {
239 try {
240 const projects = await api.projects.list();
241 if (disposed) return;
242 dispatch({
243 type: "SYNC_PROJECTS",
244 projects: projects.map((project) => ({
245 id: project.id,
246 name: project.name,
247 cwd: project.cwd,
248 model: DEFAULT_MODEL,
249 expanded: true,
250 scripts: project.scripts,
251 })),
252 });
253 dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
254 bootstrappedRef.current = true;
255 } catch {
256 if (disposed) return;
257 retryTimer = setTimeout(() => {
258 retryTimer = null;
259 void attemptBootstrap();
260 }, retryDelayMs);
261 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
262 }
263 };
264
265 void attemptBootstrap();
266
267 return () => {
268 disposed = true;
269 if (retryTimer) {
270 clearTimeout(retryTimer);
271 }
272 };
273 }, [api, dispatch]);
274
203 // Desktop now hydrates from orchestration read-model snapshots.
275204 return null;
276205}
apps/web/src/session-logic.test.ts deleted-935
......@@ -1,935 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
4import {
5 type WorkLogEntry,
6 applyEventToMessages,
7 derivePendingApprovals,
8 deriveTurnDiffFilesFromUnifiedDiff,
9 deriveTurnDiffSummaries,
10 deriveTimelineEntries,
11 deriveWorkLogEntries,
12 evolveSession,
13} from "./session-logic";
14import type { ChatMessage } from "./types";
15
16function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent {
17 return {
18 id: "evt-1",
19 kind: "notification",
20 provider: "codex",
21 sessionId: "sess-1",
22 createdAt: "2026-02-08T10:00:00.000Z",
23 method: "noop",
24 ...overrides,
25 };
26}
27
28function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
29 return {
30 sessionId: "sess-1",
31 provider: "codex",
32 status: "ready",
33 createdAt: "2026-02-08T09:59:00.000Z",
34 updatedAt: "2026-02-08T09:59:00.000Z",
35 ...overrides,
36 };
37}
38
39describe("deriveTimelineEntries", () => {
40 it("interleaves messages and work entries by timestamp", () => {
41 const messages: ChatMessage[] = [
42 {
43 id: "m-user",
44 role: "user",
45 text: "Hi",
46 createdAt: "2026-02-08T10:00:00.000Z",
47 streaming: false,
48 },
49 {
50 id: "m-assistant",
51 role: "assistant",
52 text: "Hello",
53 createdAt: "2026-02-08T10:05:00.000Z",
54 streaming: false,
55 },
56 ];
57 const workEntries: WorkLogEntry[] = [
58 {
59 id: "w-1",
60 label: "Tool call",
61 createdAt: "2026-02-08T10:02:00.000Z",
62 tone: "tool",
63 },
64 {
65 id: "w-2",
66 label: "Preamble",
67 createdAt: "2026-02-08T10:03:00.000Z",
68 tone: "thinking",
69 },
70 ];
71
72 const timeline = deriveTimelineEntries(messages, workEntries);
73
74 expect(timeline.map((entry) => entry.id)).toEqual([
75 "message:m-user",
76 "work:w-1",
77 "work:w-2",
78 "message:m-assistant",
79 ]);
80 });
81
82 it("prefers work entries when timestamps are equal", () => {
83 const messages: ChatMessage[] = [
84 {
85 id: "m-1",
86 role: "assistant",
87 text: "Done",
88 createdAt: "2026-02-08T10:00:00.000Z",
89 streaming: false,
90 },
91 ];
92 const workEntries: WorkLogEntry[] = [
93 {
94 id: "w-1",
95 label: "Tool call",
96 createdAt: "2026-02-08T10:00:00.000Z",
97 tone: "tool",
98 },
99 ];
100
101 const timeline = deriveTimelineEntries(messages, workEntries);
102
103 expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]);
104 });
105});
106
107describe("deriveWorkLogEntries", () => {
108 it("drops preamble/work events from the visible work log", () => {
109 const entries = deriveWorkLogEntries(
110 [
111 makeEvent({
112 id: "evt-1",
113 method: "item/started",
114 turnId: "turn-1",
115 createdAt: "2026-02-08T10:00:00.000Z",
116 payload: { item: { type: "preamble", text: "thinking" } },
117 }),
118 makeEvent({
119 id: "evt-2",
120 method: "item/started",
121 turnId: "turn-1",
122 createdAt: "2026-02-08T10:00:01.000Z",
123 payload: { item: { type: "work", text: "planning" } },
124 }),
125 makeEvent({
126 id: "evt-3",
127 method: "item/started",
128 turnId: "turn-1",
129 createdAt: "2026-02-08T10:00:02.000Z",
130 payload: { item: { type: "tool_call", command: "ls -la" } },
131 }),
132 ],
133 "turn-1",
134 );
135
136 expect(entries).toHaveLength(1);
137 expect(entries[0]?.label).toBe("Tool call");
138 });
139
140 it("does not surface successful turn completion as a work-log row", () => {
141 const entries = deriveWorkLogEntries(
142 [
143 makeEvent({
144 id: "evt-start",
145 method: "turn/started",
146 turnId: "turn-1",
147 createdAt: "2026-02-08T10:00:00.000Z",
148 }),
149 makeEvent({
150 id: "evt-complete",
151 method: "turn/completed",
152 turnId: "turn-1",
153 createdAt: "2026-02-08T10:00:10.000Z",
154 payload: { turn: { id: "turn-1", status: "completed" } },
155 }),
156 ],
157 "turn-1",
158 );
159
160 expect(entries).toHaveLength(0);
161 });
162
163 it("shows failed turn completion in the work log", () => {
164 const entries = deriveWorkLogEntries(
165 [
166 makeEvent({
167 id: "evt-start",
168 method: "turn/started",
169 turnId: "turn-1",
170 createdAt: "2026-02-08T10:00:00.000Z",
171 }),
172 makeEvent({
173 id: "evt-complete",
174 method: "turn/completed",
175 turnId: "turn-1",
176 createdAt: "2026-02-08T10:00:10.000Z",
177 payload: {
178 turn: {
179 id: "turn-1",
180 status: "failed",
181 error: { message: "sandbox denied" },
182 },
183 },
184 }),
185 ],
186 "turn-1",
187 );
188
189 expect(entries).toHaveLength(1);
190 expect(entries[0]?.label).toBe("Turn failed");
191 expect(entries[0]?.detail).toBe("sandbox denied");
192 });
193
194 it("hides reasoning and agent-message noise from the visible work log", () => {
195 const entries = deriveWorkLogEntries(
196 [
197 makeEvent({
198 id: "evt-turn-start",
199 method: "turn/started",
200 turnId: "turn-1",
201 createdAt: "2026-02-08T10:00:00.000Z",
202 }),
203 makeEvent({
204 id: "evt-summary-part",
205 method: "item/reasoning/summaryPartAdded",
206 turnId: "turn-1",
207 createdAt: "2026-02-08T10:00:01.000Z",
208 }),
209 makeEvent({
210 id: "evt-summary-delta",
211 method: "item/reasoning/summaryTextDelta",
212 turnId: "turn-1",
213 createdAt: "2026-02-08T10:00:02.000Z",
214 }),
215 makeEvent({
216 id: "evt-agent-start",
217 method: "item/started",
218 turnId: "turn-1",
219 createdAt: "2026-02-08T10:00:03.000Z",
220 payload: {
221 item: { id: "item-msg", type: "agentMessage", text: "Working..." },
222 },
223 }),
224 makeEvent({
225 id: "evt-agent-complete",
226 method: "item/completed",
227 turnId: "turn-1",
228 createdAt: "2026-02-08T10:00:04.000Z",
229 payload: {
230 item: {
231 id: "item-msg",
232 type: "agentMessage",
233 text: "Done response",
234 },
235 },
236 }),
237 makeEvent({
238 id: "evt-tool-start",
239 method: "item/started",
240 turnId: "turn-1",
241 createdAt: "2026-02-08T10:00:05.000Z",
242 payload: {
243 item: { id: "item-tool", type: "tool_call", command: "ls -la" },
244 },
245 }),
246 makeEvent({
247 id: "evt-tool-complete",
248 method: "item/completed",
249 turnId: "turn-1",
250 createdAt: "2026-02-08T10:00:06.000Z",
251 payload: {
252 item: { id: "item-tool", type: "tool_call", command: "ls -la" },
253 },
254 }),
255 makeEvent({
256 id: "evt-turn-complete",
257 method: "turn/completed",
258 turnId: "turn-1",
259 createdAt: "2026-02-08T10:00:08.000Z",
260 payload: { turn: { id: "turn-1", status: "completed" } },
261 }),
262 ],
263 "turn-1",
264 );
265
266 expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]);
267 });
268
269 it("coalesces command start/completed lifecycle into one entry", () => {
270 const entries = deriveWorkLogEntries(
271 [
272 makeEvent({
273 id: "evt-command-start",
274 method: "item/started",
275 turnId: "turn-1",
276 createdAt: "2026-02-08T10:00:01.000Z",
277 payload: {
278 item: {
279 id: "item-command",
280 type: "command_execution",
281 command: "git status --short",
282 },
283 },
284 }),
285 makeEvent({
286 id: "evt-command-complete",
287 method: "item/completed",
288 turnId: "turn-1",
289 createdAt: "2026-02-08T10:00:02.000Z",
290 payload: {
291 item: {
292 id: "item-command",
293 type: "command_execution",
294 command: "git status --short",
295 },
296 },
297 }),
298 ],
299 "turn-1",
300 );
301
302 expect(entries).toHaveLength(1);
303 expect(entries[0]?.label).toBe("Command run");
304 expect(entries[0]?.detail).toBe("git status --short");
305 });
306
307 it("preserves full tool-call detail text without data truncation", () => {
308 const longCommand =
309 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"';
310 const entries = deriveWorkLogEntries(
311 [
312 makeEvent({
313 id: "evt-tool-start",
314 method: "item/started",
315 turnId: "turn-1",
316 createdAt: "2026-02-08T10:00:05.000Z",
317 payload: {
318 item: { id: "item-tool", type: "tool_call", command: longCommand },
319 },
320 }),
321 ],
322 "turn-1",
323 );
324
325 expect(entries[0]?.detail).toBe(longCommand);
326 });
327
328 it("hides generic tool-call rows that have no identifying detail", () => {
329 const entries = deriveWorkLogEntries(
330 [
331 makeEvent({
332 id: "evt-tool-start",
333 method: "item/started",
334 turnId: "turn-1",
335 createdAt: "2026-02-08T10:00:05.000Z",
336 payload: {
337 item: { id: "item-tool", type: "tool_call" },
338 },
339 }),
340 ],
341 "turn-1",
342 );
343
344 expect(entries).toHaveLength(0);
345 });
346
347 it("includes collab tool-call rows with tool name detail", () => {
348 const entries = deriveWorkLogEntries(
349 [
350 makeEvent({
351 id: "evt-collab-start",
352 method: "item/started",
353 turnId: "turn-1",
354 createdAt: "2026-02-08T10:00:05.000Z",
355 payload: {
356 item: {
357 id: "item-collab",
358 type: "collabAgentToolCall",
359 tool: "spawnAgent",
360 },
361 },
362 }),
363 ],
364 "turn-1",
365 );
366
367 expect(entries).toHaveLength(1);
368 expect(entries[0]?.label).toBe("Tool call");
369 expect(entries[0]?.detail).toBe("spawnAgent");
370 });
371
372 it("can derive work-log entries across all turns when not scoped", () => {
373 const entries = deriveWorkLogEntries(
374 [
375 makeEvent({
376 id: "evt-turn-1-tool",
377 method: "item/started",
378 turnId: "turn-1",
379 createdAt: "2026-02-08T10:00:01.000Z",
380 payload: { item: { type: "tool_call", command: "ls -la" } },
381 }),
382 makeEvent({
383 id: "evt-turn-2-tool",
384 method: "item/started",
385 turnId: "turn-2",
386 createdAt: "2026-02-08T10:00:02.000Z",
387 payload: { item: { type: "tool_call", command: "pwd" } },
388 }),
389 ],
390 undefined,
391 );
392
393 expect(entries).toHaveLength(2);
394 expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]);
395 });
396});
397
398describe("deriveTurnDiffSummaries", () => {
399 it("tracks completed turns and links them to assistant message completion", () => {
400 const summaries = deriveTurnDiffSummaries([
401 makeEvent({
402 id: "evt-turn-start",
403 method: "turn/started",
404 turnId: "turn-1",
405 createdAt: "2026-02-08T10:00:00.000Z",
406 }),
407 makeEvent({
408 id: "evt-file-change",
409 method: "item/completed",
410 turnId: "turn-1",
411 createdAt: "2026-02-08T10:00:01.000Z",
412 payload: {
413 item: {
414 id: "item-file-change",
415 type: "fileChange",
416 status: "completed",
417 changes: [
418 {
419 path: "src/a.ts",
420 kind: "modified",
421 diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n",
422 },
423 ],
424 },
425 },
426 }),
427 makeEvent({
428 id: "evt-turn-diff-updated",
429 method: "turn/diff/updated",
430 turnId: "turn-1",
431 createdAt: "2026-02-08T10:00:02.000Z",
432 payload: {
433 threadId: "thr-1",
434 turnId: "turn-1",
435 diff: [
436 "diff --git a/src/a.ts b/src/a.ts",
437 "@@ -1 +1 @@",
438 "-old",
439 "+new",
440 "diff --git a/src/b.ts b/src/b.ts",
441 "@@ -0,0 +1 @@",
442 "+created",
443 ].join("\n"),
444 },
445 }),
446 makeEvent({
447 id: "evt-agent-msg",
448 method: "item/completed",
449 turnId: "turn-1",
450 createdAt: "2026-02-08T10:00:03.000Z",
451 payload: {
452 item: {
453 id: "msg-1",
454 type: "agentMessage",
455 text: "Done",
456 },
457 },
458 }),
459 makeEvent({
460 id: "evt-turn-completed",
461 method: "turn/completed",
462 turnId: "turn-1",
463 createdAt: "2026-02-08T10:00:04.000Z",
464 payload: {
465 turn: {
466 id: "turn-1",
467 status: "completed",
468 },
469 },
470 }),
471 makeEvent({
472 id: "evt-turn-2-file-change",
473 method: "item/completed",
474 turnId: "turn-2",
475 createdAt: "2026-02-08T10:00:05.000Z",
476 payload: {
477 item: {
478 id: "item-file-change-2",
479 type: "fileChange",
480 status: "completed",
481 changes: [
482 {
483 path: "src/unfinished.ts",
484 kind: "modified",
485 diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts",
486 },
487 ],
488 },
489 },
490 }),
491 ]);
492
493 expect(summaries).toHaveLength(1);
494 expect(summaries[0]?.turnId).toBe("turn-1");
495 expect(summaries[0]?.assistantMessageId).toBe("msg-1");
496 expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
497 expect(summaries[0]?.files[0]?.kind).toBe("modified");
498 expect(summaries[0]?.files[0]?.additions).toBe(1);
499 expect(summaries[0]?.files[0]?.deletions).toBe(1);
500 expect(summaries[0]).not.toHaveProperty("unifiedDiff");
501 });
502
503 it("includes completed turns even when no fileChange tool events were emitted", () => {
504 const summaries = deriveTurnDiffSummaries([
505 makeEvent({
506 id: "evt-turn-start",
507 method: "turn/started",
508 turnId: "turn-1",
509 createdAt: "2026-02-08T10:00:00.000Z",
510 }),
511 makeEvent({
512 id: "evt-turn-completed",
513 method: "turn/completed",
514 turnId: "turn-1",
515 createdAt: "2026-02-08T10:00:01.000Z",
516 payload: {
517 turn: {
518 id: "turn-1",
519 status: "completed",
520 },
521 },
522 }),
523 ]);
524
525 expect(summaries).toHaveLength(1);
526 expect(summaries[0]?.turnId).toBe("turn-1");
527 expect(summaries[0]?.files).toEqual([]);
528 });
529
530 it("keeps newest diff data when older events provide stale values", () => {
531 const summaries = deriveTurnDiffSummaries([
532 makeEvent({
533 id: "evt-turn-diff-old",
534 method: "turn/diff/updated",
535 turnId: "turn-1",
536 createdAt: "2026-02-08T10:00:01.000Z",
537 payload: {
538 diff: [
539 "diff --git a/src/example.ts b/src/example.ts",
540 "@@ -1 +1,2 @@",
541 "-old",
542 "+older",
543 "+extra",
544 ].join("\n"),
545 },
546 }),
547 makeEvent({
548 id: "evt-turn-diff-new",
549 method: "turn/diff/updated",
550 turnId: "turn-1",
551 createdAt: "2026-02-08T10:00:02.000Z",
552 payload: {
553 diff: [
554 "diff --git a/src/example.ts b/src/example.ts",
555 "@@ -1 +1 @@",
556 "-old",
557 "+newest",
558 ].join("\n"),
559 },
560 }),
561 makeEvent({
562 id: "evt-turn-completed",
563 method: "turn/completed",
564 turnId: "turn-1",
565 createdAt: "2026-02-08T10:00:03.000Z",
566 payload: {
567 turn: {
568 id: "turn-1",
569 status: "completed",
570 },
571 },
572 }),
573 ]);
574
575 expect(summaries).toHaveLength(1);
576 expect(summaries[0]?.files[0]?.path).toBe("src/example.ts");
577 expect(summaries[0]?.files[0]?.additions).toBe(1);
578 expect(summaries[0]?.files[0]?.deletions).toBe(1);
579 });
580});
581
582describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
583 it("splits a multi-file patch into sorted per-file entries", () => {
584 const files = deriveTurnDiffFilesFromUnifiedDiff(
585 [
586 "diff --git a/src/b.ts b/src/b.ts",
587 "@@ -1 +1 @@",
588 "-old-b",
589 "+new-b",
590 "diff --git a/src/a.ts b/src/a.ts",
591 "@@ -1 +1 @@",
592 "-old-a",
593 "+new-a",
594 ].join("\n"),
595 );
596
597 expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]);
598 expect(files[0]?.additions).toBe(1);
599 expect(files[0]?.deletions).toBe(1);
600 });
601
602 it("parses git headers when file paths include ` b/`", () => {
603 const files = deriveTurnDiffFilesFromUnifiedDiff(
604 [
605 "diff --git a/subdir b/example.ts b/subdir b/example.ts",
606 "--- a/subdir b/example.ts",
607 "+++ b/subdir b/example.ts",
608 "@@ -1 +1 @@",
609 "-old",
610 "+new",
611 ].join("\n"),
612 );
613
614 expect(files).toHaveLength(1);
615 expect(files[0]?.path).toBe("subdir b/example.ts");
616 });
617
618 it("parses git header fallback for binary diffs with ` b/` in path", () => {
619 const files = deriveTurnDiffFilesFromUnifiedDiff(
620 [
621 "diff --git a/subdir b/image.png b/subdir b/image.png",
622 "Binary files a/subdir b/image.png and b/subdir b/image.png differ",
623 ].join("\n"),
624 );
625
626 expect(files).toHaveLength(1);
627 expect(files[0]?.path).toBe("subdir b/image.png");
628 });
629});
630
631describe("derivePendingApprovals", () => {
632 it("returns pending command/file-change approvals", () => {
633 const approvals = derivePendingApprovals([
634 makeEvent({
635 id: "evt-request",
636 kind: "request",
637 method: "item/commandExecution/requestApproval",
638 requestId: "req-1",
639 requestKind: "command",
640 createdAt: "2026-02-08T10:00:00.000Z",
641 payload: { command: "git commit -m 'msg'" },
642 }),
643 ]);
644
645 expect(approvals).toEqual([
646 {
647 requestId: "req-1",
648 requestKind: "command",
649 createdAt: "2026-02-08T10:00:00.000Z",
650 detail: "git commit -m 'msg'",
651 },
652 ]);
653 });
654
655 it("removes approvals after a decision event", () => {
656 const approvals = derivePendingApprovals([
657 makeEvent({
658 id: "evt-decision",
659 method: "item/requestApproval/decision",
660 requestId: "req-1",
661 createdAt: "2026-02-08T10:00:01.000Z",
662 }),
663 makeEvent({
664 id: "evt-request",
665 kind: "request",
666 method: "item/fileChange/requestApproval",
667 requestId: "req-1",
668 requestKind: "file-change",
669 createdAt: "2026-02-08T10:00:00.000Z",
670 }),
671 ]);
672
673 expect(approvals).toHaveLength(0);
674 });
675
676 it("clears pending approvals after turn completion", () => {
677 const approvals = derivePendingApprovals([
678 makeEvent({
679 id: "evt-turn-complete",
680 method: "turn/completed",
681 createdAt: "2026-02-08T10:00:01.000Z",
682 payload: { turn: { id: "turn-1", status: "completed" } },
683 }),
684 makeEvent({
685 id: "evt-request",
686 kind: "request",
687 method: "item/commandExecution/requestApproval",
688 requestId: "req-1",
689 requestKind: "command",
690 createdAt: "2026-02-08T10:00:00.000Z",
691 }),
692 ]);
693
694 expect(approvals).toHaveLength(0);
695 });
696});
697
698describe("evolveSession", () => {
699 it("updates thread id when thread starts", () => {
700 const previous = makeSession();
701 const next = evolveSession(
702 previous,
703 makeEvent({
704 method: "thread/started",
705 createdAt: "2026-02-08T10:01:00.000Z",
706 payload: { thread: { id: "thread-1" } },
707 }),
708 );
709
710 expect(next.threadId).toBe("thread-1");
711 expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z");
712 });
713
714 it("moves to running and records active turn on turn start", () => {
715 const previous = makeSession();
716 const next = evolveSession(
717 previous,
718 makeEvent({
719 method: "turn/started",
720 turnId: "turn-1",
721 createdAt: "2026-02-08T10:02:00.000Z",
722 }),
723 );
724
725 expect(next.status).toBe("running");
726 expect(next.activeTurnId).toBe("turn-1");
727 expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z");
728 });
729
730 it("returns to ready and clears active turn on successful completion", () => {
731 const previous = makeSession({
732 status: "running",
733 activeTurnId: "turn-1",
734 lastError: "older error",
735 });
736 const next = evolveSession(
737 previous,
738 makeEvent({
739 method: "turn/completed",
740 createdAt: "2026-02-08T10:03:00.000Z",
741 payload: { turn: { id: "turn-1", status: "completed" } },
742 }),
743 );
744
745 expect(next.status).toBe("ready");
746 expect(next.activeTurnId).toBeUndefined();
747 expect(next.lastError).toBe("older error");
748 });
749
750 it("marks session as error when turn fails", () => {
751 const previous = makeSession({
752 status: "running",
753 activeTurnId: "turn-1",
754 });
755 const next = evolveSession(
756 previous,
757 makeEvent({
758 method: "turn/completed",
759 createdAt: "2026-02-08T10:03:00.000Z",
760 payload: {
761 turn: { id: "turn-1", status: "failed", error: { message: "boom" } },
762 },
763 }),
764 );
765
766 expect(next.status).toBe("error");
767 expect(next.activeTurnId).toBeUndefined();
768 expect(next.lastError).toBe("boom");
769 });
770
771 it("moves to error on runtime error events", () => {
772 const previous = makeSession();
773 const next = evolveSession(
774 previous,
775 makeEvent({
776 kind: "error",
777 method: "runtime/error",
778 createdAt: "2026-02-08T10:04:00.000Z",
779 message: "runtime failure",
780 }),
781 );
782
783 expect(next.status).toBe("error");
784 expect(next.lastError).toBe("runtime failure");
785 });
786
787 it("closes session on close lifecycle events", () => {
788 const previous = makeSession({
789 status: "running",
790 activeTurnId: "turn-1",
791 });
792 const next = evolveSession(
793 previous,
794 makeEvent({
795 method: "session/closed",
796 createdAt: "2026-02-08T10:05:00.000Z",
797 message: "closed",
798 }),
799 );
800
801 expect(next.status).toBe("closed");
802 expect(next.activeTurnId).toBeUndefined();
803 expect(next.lastError).toBe("closed");
804 });
805});
806
807describe("applyEventToMessages", () => {
808 it("handles start/delta/completed flow for assistant messages", () => {
809 const activeAssistantItemRef = { current: null as string | null };
810 const started = applyEventToMessages(
811 [],
812 makeEvent({
813 method: "item/started",
814 createdAt: "2026-02-08T10:00:00.000Z",
815 payload: {
816 item: {
817 id: "item-1",
818 type: "agentMessage",
819 text: "Hello",
820 },
821 },
822 }),
823 activeAssistantItemRef,
824 );
825
826 expect(started).toEqual([
827 {
828 id: "item-1",
829 role: "assistant",
830 text: "Hello",
831 createdAt: "2026-02-08T10:00:00.000Z",
832 streaming: true,
833 },
834 ]);
835 expect(activeAssistantItemRef.current).toBe("item-1");
836
837 const withDelta = applyEventToMessages(
838 started,
839 makeEvent({
840 method: "item/agentMessage/delta",
841 createdAt: "2026-02-08T10:00:01.000Z",
842 itemId: "item-1",
843 textDelta: " world",
844 }),
845 activeAssistantItemRef,
846 );
847 expect(withDelta[0]?.text).toBe("Hello world");
848 expect(withDelta[0]?.streaming).toBe(true);
849
850 const completed = applyEventToMessages(
851 withDelta,
852 makeEvent({
853 method: "item/completed",
854 createdAt: "2026-02-08T10:00:02.000Z",
855 payload: {
856 item: {
857 id: "item-1",
858 type: "agentMessage",
859 text: "Hello world!",
860 },
861 },
862 }),
863 activeAssistantItemRef,
864 );
865 expect(completed[0]?.text).toBe("Hello world!");
866 expect(completed[0]?.streaming).toBe(false);
867 expect(activeAssistantItemRef.current).toBeNull();
868 });
869
870 it("supports out-of-order delta before started/completed", () => {
871 const activeAssistantItemRef = { current: null as string | null };
872 const withDelta = applyEventToMessages(
873 [],
874 makeEvent({
875 method: "item/agentMessage/delta",
876 createdAt: "2026-02-08T10:00:01.000Z",
877 itemId: "item-2",
878 textDelta: "Partial",
879 }),
880 activeAssistantItemRef,
881 );
882 expect(withDelta[0]?.id).toBe("item-2");
883 expect(withDelta[0]?.text).toBe("Partial");
884 expect(withDelta[0]?.streaming).toBe(true);
885 expect(activeAssistantItemRef.current).toBe("item-2");
886
887 const completed = applyEventToMessages(
888 withDelta,
889 makeEvent({
890 method: "item/completed",
891 createdAt: "2026-02-08T10:00:02.000Z",
892 payload: {
893 item: {
894 id: "item-2",
895 type: "agentMessage",
896 text: "Partial + final",
897 },
898 },
899 }),
900 activeAssistantItemRef,
901 );
902
903 expect(completed[0]?.text).toBe("Partial + final");
904 expect(completed[0]?.streaming).toBe(false);
905 });
906
907 it("clears all streaming flags when turn completes", () => {
908 const previous: ChatMessage[] = [
909 {
910 id: "m-user",
911 role: "user",
912 text: "Hi",
913 createdAt: "2026-02-08T10:00:00.000Z",
914 streaming: false,
915 },
916 {
917 id: "m-assistant",
918 role: "assistant",
919 text: "Typing",
920 createdAt: "2026-02-08T10:00:01.000Z",
921 streaming: true,
922 },
923 ];
924
925 const next = applyEventToMessages(
926 previous,
927 makeEvent({
928 method: "turn/completed",
929 }),
930 { current: null },
931 );
932
933 expect(next.every((entry) => entry.streaming === false)).toBe(true);
934 });
935});
apps/web/src/session-logic.ts+65-877
......@@ -1,6 +1,6 @@
1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
1import type { ProviderKind, ProviderSession } from "@t3tools/contracts";
2
23import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types";
3import { createWsNativeApi } from "./wsNativeApi";
44
55export const PROVIDER_OPTIONS: Array<{
66 value: ProviderKind;
......@@ -11,73 +11,6 @@ export const PROVIDER_OPTIONS: Array<{
1111 { value: "claudeCode", label: "Claude Code (soon)", available: false },
1212];
1313
14let cachedApi: NativeApi | undefined;
15
16export function readNativeApi(): NativeApi | undefined {
17 if (typeof window === "undefined") return undefined;
18 if (cachedApi) return cachedApi;
19
20 // Prefer Electron preload bridge if available
21 if (window.nativeApi) {
22 cachedApi = window.nativeApi;
23 return cachedApi;
24 }
25
26 // Fall back to WebSocket transport
27 cachedApi = createWsNativeApi();
28 return cachedApi;
29}
30
31export function asObject(value: unknown): Record<string, unknown> | undefined {
32 if (!value || typeof value !== "object") return undefined;
33 return value as Record<string, unknown>;
34}
35
36function asArray(value: unknown): unknown[] | undefined {
37 return Array.isArray(value) ? value : undefined;
38}
39
40export function asString(value: unknown): string | undefined {
41 return typeof value === "string" ? value : undefined;
42}
43
44function asNumber(value: unknown): number | undefined {
45 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
46}
47
48export function formatTimestamp(isoDate: string): string {
49 return new Intl.DateTimeFormat(undefined, {
50 hour: "numeric",
51 minute: "2-digit",
52 second: "2-digit",
53 }).format(new Date(isoDate));
54}
55
56export function formatDuration(durationMs: number): string {
57 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
58 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
59 if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
60 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
61
62 const minutes = Math.floor(durationMs / 60_000);
63 const seconds = Math.round((durationMs % 60_000) / 1_000);
64 if (seconds === 0) return `${minutes}m`;
65 if (seconds === 60) return `${minutes + 1}m`;
66 return `${minutes}m ${seconds}s`;
67}
68
69export function formatElapsed(startIso: string, endIso: string | undefined): string | null {
70 if (!endIso) return null;
71
72 const startedAt = Date.parse(startIso);
73 const endedAt = Date.parse(endIso);
74 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
75 return null;
76 }
77
78 return formatDuration(endedAt - startedAt);
79}
80
8114export interface WorkLogEntry {
8215 id: string;
8316 createdAt: string;
......@@ -107,680 +40,94 @@ export type TimelineEntry =
10740 entry: WorkLogEntry;
10841 };
10942
110export type { TurnDiffFileChange, TurnDiffSummary } from "./types";
111
112function normalizeDetail(value: string | undefined): string | undefined {
113 if (!value) return undefined;
114 const trimmed = value.trim();
115 return trimmed.length > 0 ? trimmed : undefined;
116}
117
118function approvalDetail(event: ProviderEvent): string | undefined {
119 const payload = asObject(event.payload);
120 const command = asString(payload?.command);
121 if (command) return command;
122 return asString(payload?.reason);
123}
124
125export function derivePendingApprovals(events: ProviderEvent[]): PendingApproval[] {
126 const pending = new Map<string, PendingApproval>();
127 const ordered = [...events].toReversed();
128
129 for (const event of ordered) {
130 if (
131 event.method === "session/closed" ||
132 event.method === "session/exited" ||
133 event.method === "turn/completed"
134 ) {
135 pending.clear();
136 continue;
137 }
138
139 const requestId = event.requestId ?? asString(asObject(event.payload)?.requestId);
140 if (!requestId) continue;
141
142 if (
143 event.kind === "request" &&
144 (event.requestKind === "command" || event.requestKind === "file-change")
145 ) {
146 const detail = approvalDetail(event);
147 pending.set(requestId, {
148 requestId,
149 requestKind: event.requestKind,
150 createdAt: event.createdAt,
151 ...(detail ? { detail } : {}),
152 });
153 continue;
154 }
155
156 if (event.method === "item/requestApproval/decision") {
157 pending.delete(requestId);
158 }
159 }
160
161 return Array.from(pending.values());
162}
163
164function normalizeItemType(raw: string | undefined): string {
165 if (!raw) return "item";
166 return raw
167 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
168 .replace(/[._/-]/g, " ")
169 .replace(/\s+/g, " ")
170 .trim()
171 .toLowerCase();
172}
173
174function shouldDropItemType(type: string): boolean {
175 if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) {
176 return true;
177 }
178
179 return type === "work" || type.startsWith("work ");
180}
181
182function shouldShowItemLifecycle(type: string): boolean {
183 return type.includes("tool") || type.includes("command") || type.includes("file change");
184}
185
186function shouldDropMethod(method: string): boolean {
187 return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
188}
189
190function itemTypeMeta(type: string): {
191 label: string;
192 tone: WorkLogEntry["tone"];
193} {
194 if (type.includes("command")) {
195 return { label: "Command run", tone: "tool" };
196 }
197 if (type.includes("file change")) {
198 return { label: "File change", tone: "tool" };
199 }
200 if (type.includes("tool")) {
201 return { label: "Tool call", tone: "tool" };
202 }
203 return { label: "Work item", tone: "info" };
204}
205
206interface ItemLifecycleCandidate {
207 id: string;
208 itemId?: string;
209 createdAt: string;
210 label: string;
211 detail?: string;
212 tone: WorkLogEntry["tone"];
213 phase: "started" | "completed";
214}
215
216function extractDetail(
217 payload: Record<string, unknown> | undefined,
218 item: Record<string, unknown> | undefined,
219): string | undefined {
220 const candidates = [
221 asString(item?.command),
222 asString(item?.tool),
223 asString(item?.name),
224 asString(item?.title),
225 asString(item?.summary),
226 asString(item?.text),
227 asString(item?.prompt),
228 asString(payload?.message),
229 asString(payload?.prompt),
230 asString(payload?.command),
231 ];
232
233 for (const candidate of candidates) {
234 const detail = normalizeDetail(candidate);
235 if (detail) return detail;
236 }
237 return undefined;
238}
239
240function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null {
241 const payload = asObject(event.payload);
242 const item = asObject(payload?.item);
243 const normalizedType = normalizeItemType(asString(item?.type));
244 if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) {
245 return null;
246 }
247 if (!shouldShowItemLifecycle(normalizedType)) {
248 return null;
249 }
250
251 const meta = itemTypeMeta(normalizedType);
252 const isStarted = event.method === "item/started";
253 const isCompleted = event.method === "item/completed";
254 if (!isStarted && !isCompleted) {
255 return null;
256 }
257
258 const detail = extractDetail(payload, item);
259 const itemId = event.itemId ?? asString(item?.id);
260 return {
261 id: event.id,
262 ...(itemId ? { itemId } : {}),
263 createdAt: event.createdAt,
264 label: meta.label,
265 ...(detail ? { detail } : {}),
266 tone: meta.tone,
267 phase: isCompleted ? "completed" : "started",
268 };
43export function formatTimestamp(isoDate: string): string {
44 return new Intl.DateTimeFormat(undefined, {
45 hour: "numeric",
46 minute: "2-digit",
47 second: "2-digit",
48 }).format(new Date(isoDate));
26949}
27050
271function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
272 if (event.kind !== "request") return null;
273 if (shouldDropMethod(event.method)) return null;
274
275 if (event.method.includes("commandExecution")) {
276 return {
277 id: event.id,
278 createdAt: event.createdAt,
279 label: "Command approval requested",
280 tone: "tool",
281 };
282 }
283
284 if (event.method.includes("fileChange")) {
285 return {
286 id: event.id,
287 createdAt: event.createdAt,
288 label: "File-change approval requested",
289 tone: "tool",
290 };
291 }
292
293 if (event.method.includes("requestUserInput")) {
294 return {
295 id: event.id,
296 createdAt: event.createdAt,
297 label: "Tool requested user input",
298 tone: "tool",
299 };
300 }
301
302 return {
303 id: event.id,
304 createdAt: event.createdAt,
305 label: `Request: ${event.method}`,
306 tone: "info",
307 };
51export function formatDuration(durationMs: number): string {
52 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
53 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
54 if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
55 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
56 const minutes = Math.floor(durationMs / 60_000);
57 const seconds = Math.round((durationMs % 60_000) / 1_000);
58 if (seconds === 0) return `${minutes}m`;
59 if (seconds === 60) return `${minutes + 1}m`;
60 return `${minutes}m ${seconds}s`;
30861}
30962
310function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
311 if (event.kind !== "notification") return null;
312 if (shouldDropMethod(event.method)) return null;
313 if (event.method === "item/agentMessage/delta") return null;
314 if (event.method === "turn/started") {
63export function formatElapsed(startIso: string, endIso: string | undefined): string | null {
64 if (!endIso) return null;
65 const startedAt = Date.parse(startIso);
66 const endedAt = Date.parse(endIso);
67 if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
31568 return null;
31669 }
317 if (event.method === "thread/started") return null;
318
319 if (event.method === "turn/completed") {
320 const payload = asObject(event.payload);
321 const turn = asObject(payload?.turn);
322 const status = asString(turn?.status);
323 if (status !== "failed") {
324 return null;
325 }
326 const turnError = asObject(turn?.error);
327 const turnErrorMessage = asString(turnError?.message);
328 const turnErrorDetail = normalizeDetail(turnErrorMessage);
329
330 return {
331 id: event.id,
332 createdAt: event.createdAt,
333 label: "Turn failed",
334 ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),
335 tone: "error",
336 };
337 }
338
339 if (event.method.startsWith("item/")) return null;
340
341 return null;
342}
343
344function entryFromError(event: ProviderEvent): WorkLogEntry | null {
345 if (event.kind !== "error") return null;
346 if (shouldDropMethod(event.method)) return null;
347 const detail = normalizeDetail(event.message);
348
349 return {
350 id: event.id,
351 createdAt: event.createdAt,
352 label: "Runtime error",
353 ...(detail ? { detail } : {}),
354 tone: "error",
355 };
70 return formatDuration(endedAt - startedAt);
35671}
35772
358function eventTurnId(event: ProviderEvent): string | undefined {
359 const payload = asObject(event.payload);
360 const turn = asObject(payload?.turn);
361 return event.turnId ?? asString(turn?.id);
73export function derivePendingApprovals(_events: unknown[]): PendingApproval[] {
74 return [];
36275}
36376
36477export function deriveWorkLogEntries(
365 events: ProviderEvent[],
366 turnId: string | undefined,
78 _events: unknown[],
79 _latestTurnId: string | undefined,
36780): WorkLogEntry[] {
368 const ordered = [...events].toReversed();
369 const entries: WorkLogEntry[] = [];
370 const turnStartedAtIso = turnId
371 ? ordered.find((event) => {
372 if (event.method !== "turn/started") return false;
373 return eventTurnId(event) === turnId;
374 })?.createdAt
375 : undefined;
376 const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN;
377
378 const shouldIncludeEvent = (event: ProviderEvent): boolean => {
379 if (!turnId) return true;
380 const scopedTurnId = eventTurnId(event);
381 if (scopedTurnId && scopedTurnId !== turnId) {
382 return false;
383 }
384
385 if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
386 const eventAt = Date.parse(event.createdAt);
387 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
388 return false;
389 }
390 }
391
392 return true;
393 };
394
395 const completedLifecycleItemIds = new Set<string>();
396 for (const event of ordered) {
397 if (!shouldIncludeEvent(event)) continue;
398 const candidate = lifecycleCandidateFromItemEvent(event);
399 if (candidate?.phase === "completed" && candidate.itemId) {
400 completedLifecycleItemIds.add(candidate.itemId);
401 }
402 }
403
404 for (const event of ordered) {
405 if (!shouldIncludeEvent(event)) continue;
406
407 const lifecycleCandidate = lifecycleCandidateFromItemEvent(event);
408 if (lifecycleCandidate) {
409 if (
410 lifecycleCandidate.phase === "started" &&
411 lifecycleCandidate.itemId &&
412 completedLifecycleItemIds.has(lifecycleCandidate.itemId)
413 ) {
414 continue;
415 }
416 if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) {
417 continue;
418 }
419
420 entries.push({
421 id: lifecycleCandidate.id,
422 createdAt: lifecycleCandidate.createdAt,
423 label: lifecycleCandidate.label,
424 ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}),
425 tone: lifecycleCandidate.tone,
426 });
427 continue;
428 }
429
430 const fromRequest = entryFromRequest(event);
431 if (fromRequest) {
432 entries.push(fromRequest);
433 continue;
434 }
435
436 const fromNotification = entryFromNotification(event);
437 if (fromNotification) {
438 entries.push(fromNotification);
439 continue;
440 }
441
442 const fromError = entryFromError(event);
443 if (fromError) {
444 entries.push(fromError);
445 }
446 }
447
448 return entries;
449}
450
451interface MutableTurnDiffSummary {
452 turnId: string;
453 completedAt: string | undefined;
454 status: string | undefined;
455 assistantMessageId: string | undefined;
456 hasTurnDiffUpdate: boolean;
457 filesByPath: Map<string, TurnDiffFileChange>;
458}
459
460function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] {
461 if (event.method !== "item/completed") {
462 return [];
463 }
464
465 const item = asObject(asObject(event.payload)?.item);
466 const itemType = normalizeItemType(asString(item?.type));
467 if (itemType !== "file change") {
468 return [];
469 }
470
471 const changes = asArray(item?.changes) ?? [];
472 const parsed: TurnDiffFileChange[] = [];
473 for (const rawChange of changes) {
474 const change = asObject(rawChange);
475 if (!change) continue;
476
477 const diff = normalizeDetail(asString(change.diff));
478 const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined);
479 if (!path) continue;
480
481 const additions = asNumber(change.additions);
482 const deletions = asNumber(change.deletions);
483 const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null;
484 const kind = normalizeDetail(asString(change.kind));
485 parsed.push({
486 path,
487 ...(kind ? { kind } : {}),
488 ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}),
489 ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}),
490 });
491 }
492
493 return parsed;
494}
495
496function mergeTurnDiffFileChange(
497 byPath: Map<string, TurnDiffFileChange>,
498 incoming: TurnDiffFileChange,
499): void {
500 const existing = byPath.get(incoming.path);
501 if (!existing) {
502 byPath.set(incoming.path, incoming);
503 return;
504 }
505
506 if (existing.kind === undefined && incoming.kind !== undefined) {
507 existing.kind = incoming.kind;
508 }
509 if (existing.additions === undefined && incoming.additions !== undefined) {
510 existing.additions = incoming.additions;
511 }
512 if (existing.deletions === undefined && incoming.deletions !== undefined) {
513 existing.deletions = incoming.deletions;
514 }
81 return [];
51582}
51683
517function parsePathFromDiff(diff: string): string | undefined {
518 const normalized = diff.replace(/\r\n/g, "\n");
519 const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m);
520 if (bPath && bPath[1]) return bPath[1];
521 const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m);
522 if (gitHeader && gitHeader[1]) return gitHeader[1];
523 const direct = normalized.match(/^\+\+\+ (.+)$/m);
524 if (!direct || !direct[1] || direct[1] === "/dev/null") {
525 return undefined;
526 }
527 return direct[1];
528}
529
530export function splitUnifiedDiffByFile(diff: string): Map<string, string> {
531 const normalized = diff.replace(/\r\n/g, "\n");
532 const byPath = new Map<string, string>();
533 const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)];
534
535 if (headerMatches.length === 0) {
536 const path = parsePathFromDiff(normalized);
537 if (path) {
538 byPath.set(path, normalized.trim());
539 }
540 return byPath;
541 }
542
543 for (let index = 0; index < headerMatches.length; index += 1) {
544 const match = headerMatches[index];
545 if (!match) continue;
546 const start = match.index ?? 0;
547 const nextStart = headerMatches[index + 1]?.index ?? normalized.length;
548 const segment = normalized.slice(start, nextStart).trim();
549 const path = parsePathFromDiff(segment);
550 if (!path || segment.length === 0) continue;
551 byPath.set(path, segment);
552 }
553
554 return byPath;
84export function deriveTimelineEntries(
85 messages: ChatMessage[],
86 workEntries: WorkLogEntry[],
87): TimelineEntry[] {
88 const messageRows: TimelineEntry[] = messages.map((message) => ({
89 id: message.id,
90 kind: "message",
91 createdAt: message.createdAt,
92 message,
93 }));
94 const workRows: TimelineEntry[] = workEntries.map((entry) => ({
95 id: entry.id,
96 kind: "work",
97 createdAt: entry.createdAt,
98 entry,
99 }));
100 return [...messageRows, ...workRows].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
555101}
556102
557103export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {
558 const fileDiffsByPath = splitUnifiedDiffByFile(diff);
559 return Array.from(fileDiffsByPath.entries())
560 .map(([path, fileDiff]) => {
561 const stat = countDiffStat(fileDiff);
562 return {
563 path,
564 additions: stat.additions,
565 deletions: stat.deletions,
566 };
567 })
568 .toSorted((a, b) => a.path.localeCompare(b.path));
104 const files: TurnDiffFileChange[] = [];
105 const lines = diff.split("\n");
106 for (const line of lines) {
107 if (!line.startsWith("diff --git ")) continue;
108 const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
109 const filePath = match?.[2] ?? match?.[1];
110 if (!filePath) continue;
111 files.push({ path: filePath });
112 }
113 return files;
569114}
570115
571116export function inferCheckpointTurnCountByTurnId(
572117 summaries: TurnDiffSummary[],
573118): Record<string, number> {
574 if (summaries.length === 0) {
575 return {};
576 }
577
578 const sorted = [...summaries].toSorted((a, b) => {
579 const aTime = Date.parse(a.completedAt);
580 const bTime = Date.parse(b.completedAt);
581 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
582 return a.completedAt.localeCompare(b.completedAt);
583 }
584 return aTime - bTime;
585 });
586
587 const next: Record<string, number> = {};
588 let fallbackTurnCount = 1;
589 for (const summary of sorted) {
590 if (typeof summary.checkpointTurnCount === "number") {
591 next[summary.turnId] = summary.checkpointTurnCount;
592 fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1);
593 continue;
594 }
595 next[summary.turnId] = fallbackTurnCount;
596 fallbackTurnCount += 1;
119 const sorted = [...summaries].sort((a, b) => a.completedAt.localeCompare(b.completedAt));
120 const result: Record<string, number> = {};
121 for (let index = 0; index < sorted.length; index += 1) {
122 const summary = sorted[index];
123 if (!summary) continue;
124 result[summary.turnId] = index + 1;
597125 }
598
599 return next;
126 return result;
600127}
601128
602export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } {
603 if (!patch) {
604 return { additions: 0, deletions: 0 };
605 }
606
607 let additions = 0;
608 let deletions = 0;
609 for (const line of patch.replace(/\r\n/g, "\n").split("\n")) {
610 if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
611 if (line.startsWith("+")) {
612 additions += 1;
613 continue;
614 }
615 if (line.startsWith("-")) {
616 deletions += 1;
617 }
618 }
619
620 return { additions, deletions };
621}
622
623export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] {
624 const firstEventAt = Date.parse(events[0]?.createdAt ?? "");
625 const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? "");
626 const ordered =
627 events.length < 2 ||
628 Number.isNaN(firstEventAt) ||
629 Number.isNaN(lastEventAt) ||
630 firstEventAt >= lastEventAt
631 ? events
632 : [...events].toReversed();
633 const byTurnId = new Map<string, MutableTurnDiffSummary>();
634
635 const ensureSummary = (turnId: string): MutableTurnDiffSummary => {
636 const existing = byTurnId.get(turnId);
637 if (existing) return existing;
638 const next: MutableTurnDiffSummary = {
639 turnId,
640 completedAt: undefined,
641 status: undefined,
642 assistantMessageId: undefined,
643 hasTurnDiffUpdate: false,
644 filesByPath: new Map<string, TurnDiffFileChange>(),
645 };
646 byTurnId.set(turnId, next);
647 return next;
648 };
649
650 for (const event of ordered) {
651 const turnId = eventTurnId(event);
652 if (!turnId) continue;
653 const summary = ensureSummary(turnId);
654
655 if (event.method === "turn/completed") {
656 if (summary.completedAt === undefined) {
657 summary.completedAt = event.createdAt;
658 }
659 const turn = asObject(asObject(event.payload)?.turn);
660 if (summary.status === undefined) {
661 summary.status = normalizeDetail(asString(turn?.status));
662 }
663 }
664
665 if (event.method === "item/completed") {
666 const item = asObject(asObject(event.payload)?.item);
667 if (asString(item?.type) === "agentMessage") {
668 const itemId = normalizeDetail(asString(item?.id));
669 if (itemId && summary.assistantMessageId === undefined) {
670 summary.assistantMessageId = itemId;
671 }
672 }
673 }
674
675 if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) {
676 summary.hasTurnDiffUpdate = true;
677 const diff = normalizeDetail(asString(asObject(event.payload)?.diff));
678 if (diff) {
679 for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) {
680 mergeTurnDiffFileChange(summary.filesByPath, file);
681 }
682 }
683 }
684
685 for (const file of parseFileChangeEntriesFromEvent(event)) {
686 mergeTurnDiffFileChange(summary.filesByPath, file);
687 }
688 }
689
690 const summaries: TurnDiffSummary[] = [];
691 for (const summary of byTurnId.values()) {
692 if (!summary.completedAt) continue;
693
694 summaries.push({
695 turnId: summary.turnId,
696 completedAt: summary.completedAt,
697 ...(summary.status ? { status: summary.status } : {}),
698 files: Array.from(summary.filesByPath.values()).toSorted((a, b) =>
699 a.path.localeCompare(b.path),
700 ),
701 ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}),
702 });
703 }
704
705 summaries.sort((a, b) => {
706 const aTime = Date.parse(a.completedAt);
707 const bTime = Date.parse(b.completedAt);
708 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
709 return b.completedAt.localeCompare(a.completedAt);
710 }
711 return bTime - aTime;
712 });
713
714 return summaries;
715}
716
717function toTimestamp(isoDate: string): number {
718 const parsed = Date.parse(isoDate);
719 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
720}
721
722export function deriveTimelineEntries(
723 messages: ChatMessage[],
724 workEntries: WorkLogEntry[],
725): TimelineEntry[] {
726 const timeline: TimelineEntry[] = [];
727 let messageIndex = 0;
728 let workIndex = 0;
729
730 while (messageIndex < messages.length || workIndex < workEntries.length) {
731 const message = messages[messageIndex];
732 const workEntry = workEntries[workIndex];
733
734 if (!message && workEntry) {
735 timeline.push({
736 id: `work:${workEntry.id}`,
737 kind: "work",
738 createdAt: workEntry.createdAt,
739 entry: workEntry,
740 });
741 workIndex += 1;
742 continue;
743 }
744
745 if (!workEntry && message) {
746 timeline.push({
747 id: `message:${message.id}`,
748 kind: "message",
749 createdAt: message.createdAt,
750 message,
751 });
752 messageIndex += 1;
753 continue;
754 }
755
756 if (!message || !workEntry) {
757 break;
758 }
759
760 const messageAt = toTimestamp(message.createdAt);
761 const workAt = toTimestamp(workEntry.createdAt);
762
763 if (workAt <= messageAt) {
764 timeline.push({
765 id: `work:${workEntry.id}`,
766 kind: "work",
767 createdAt: workEntry.createdAt,
768 entry: workEntry,
769 });
770 workIndex += 1;
771 continue;
772 }
773
774 timeline.push({
775 id: `message:${message.id}`,
776 kind: "message",
777 createdAt: message.createdAt,
778 message,
779 });
780 messageIndex += 1;
781 }
782
783 return timeline;
129export function deriveTurnDiffSummaries(_events: unknown[]): TurnDiffSummary[] {
130 return [];
784131}
785132
786133export function derivePhase(session: ProviderSession | null): SessionPhase {
......@@ -789,162 +136,3 @@ export function derivePhase(session: ProviderSession | null): SessionPhase {
789136 if (session.status === "running") return "running";
790137 return "ready";
791138}
792
793export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession {
794 const payload = asObject(event.payload);
795
796 if (event.method === "thread/started") {
797 const thread = asObject(payload?.thread);
798 return {
799 ...previous,
800 threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId,
801 updatedAt: event.createdAt,
802 };
803 }
804
805 if (event.method === "turn/started") {
806 const turn = asObject(payload?.turn);
807 return {
808 ...previous,
809 status: "running",
810 activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId,
811 updatedAt: event.createdAt,
812 };
813 }
814
815 if (event.method === "turn/completed") {
816 const turn = asObject(payload?.turn);
817 const status = asString(turn?.status);
818 const turnError = asObject(turn?.error);
819 return {
820 ...previous,
821 status: status === "failed" ? "error" : "ready",
822 activeTurnId: undefined,
823 lastError: asString(turnError?.message) ?? previous.lastError,
824 updatedAt: event.createdAt,
825 };
826 }
827
828 if (event.kind === "error") {
829 return {
830 ...previous,
831 status: "error",
832 lastError: event.message ?? previous.lastError,
833 updatedAt: event.createdAt,
834 };
835 }
836
837 if (event.method === "session/closed" || event.method === "session/exited") {
838 return {
839 ...previous,
840 status: "closed",
841 activeTurnId: undefined,
842 lastError: event.message ?? previous.lastError,
843 updatedAt: event.createdAt,
844 };
845 }
846
847 return { ...previous, updatedAt: event.createdAt };
848}
849
850export function applyEventToMessages(
851 previous: ChatMessage[],
852 event: ProviderEvent,
853 activeAssistantItemRef: { current: string | null },
854): ChatMessage[] {
855 const payload = asObject(event.payload);
856
857 if (event.method === "item/started") {
858 const item = asObject(payload?.item);
859 if (asString(item?.type) !== "agentMessage") return previous;
860 const itemId = asString(item?.id);
861 if (!itemId) return previous;
862
863 activeAssistantItemRef.current = itemId;
864 const seedText = asString(item?.text) ?? "";
865 const filtered = previous.filter((entry) => entry.id !== itemId);
866 return [
867 ...filtered,
868 {
869 id: itemId,
870 role: "assistant",
871 text: seedText,
872 createdAt: event.createdAt,
873 streaming: true,
874 },
875 ];
876 }
877
878 if (event.method === "item/agentMessage/delta") {
879 const itemId = event.itemId ?? asString(payload?.itemId);
880 const delta = event.textDelta ?? asString(payload?.delta) ?? "";
881 if (!itemId || !delta) return previous;
882
883 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
884 if (existingIndex === -1) {
885 activeAssistantItemRef.current = itemId;
886 return [
887 ...previous,
888 {
889 id: itemId,
890 role: "assistant",
891 text: delta,
892 createdAt: event.createdAt,
893 streaming: true,
894 },
895 ];
896 }
897
898 const updated = [...previous];
899 const existing = updated[existingIndex];
900 if (!existing) return previous;
901 updated[existingIndex] = {
902 ...existing,
903 text: `${existing.text}${delta}`,
904 streaming: true,
905 };
906 return updated;
907 }
908
909 if (event.method === "item/completed") {
910 const item = asObject(payload?.item);
911 if (asString(item?.type) !== "agentMessage") return previous;
912 const itemId = asString(item?.id);
913 if (!itemId) return previous;
914
915 const fullText = asString(item?.text);
916 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
917 if (existingIndex === -1) {
918 return [
919 ...previous,
920 {
921 id: itemId,
922 role: "assistant",
923 text: fullText ?? "",
924 createdAt: event.createdAt,
925 streaming: false,
926 },
927 ];
928 }
929
930 const updated = [...previous];
931 const existing = updated[existingIndex];
932 if (!existing) return previous;
933 updated[existingIndex] = {
934 ...existing,
935 text: fullText ?? existing.text,
936 streaming: false,
937 };
938
939 if (activeAssistantItemRef.current === itemId) {
940 activeAssistantItemRef.current = null;
941 }
942 return updated;
943 }
944
945 if (event.method === "turn/completed") {
946 return previous.map((entry) => ({ ...entry, streaming: false }));
947 }
948
949 return previous;
950}
apps/web/src/store.test.ts deleted-983
......@@ -1,983 +0,0 @@
1import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { type AppState, reducer } from "./store";
5import {
6 DEFAULT_THREAD_TERMINAL_HEIGHT,
7 DEFAULT_THREAD_TERMINAL_ID,
8 MAX_THREAD_TERMINAL_COUNT,
9} from "./types";
10import type { Thread } from "./types";
11
12type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;
13type TerminalActivityEvent = Extract<TerminalEvent, { type: "activity" }>;
14
15function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession {
16 return {
17 sessionId: "sess-1",
18 provider: "codex",
19 status: "ready",
20 createdAt: "2026-02-09T00:00:00.000Z",
21 updatedAt: "2026-02-09T00:00:00.000Z",
22 ...overrides,
23 };
24}
25
26function makeEvent(overrides: Partial<ProviderEvent> = {}): ProviderEvent {
27 return {
28 id: "evt-1",
29 kind: "notification",
30 provider: "codex",
31 sessionId: "sess-1",
32 createdAt: "2026-02-09T00:00:01.000Z",
33 method: "thread/started",
34 ...overrides,
35 };
36}
37
38function makeThread(overrides: Partial<Thread> = {}): Thread {
39 return {
40 id: "thread-local-1",
41 codexThreadId: null,
42 projectId: "project-1",
43 title: "Thread",
44 model: "gpt-5.3-codex",
45 terminalOpen: false,
46 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
47 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
48 runningTerminalIds: [],
49 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
50 terminalGroups: [
51 {
52 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
53 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
54 },
55 ],
56 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
57 session: makeSession(),
58 messages: [],
59 events: [],
60 turnDiffSummaries: [],
61 error: null,
62 createdAt: "2026-02-09T00:00:00.000Z",
63 branch: null,
64 worktreePath: null,
65 ...overrides,
66 };
67}
68
69function makeState(thread: Thread): AppState {
70 return {
71 projects: [
72 {
73 id: "project-1",
74 name: "Project",
75 cwd: "/tmp/project",
76 model: "gpt-5.3-codex",
77 expanded: true,
78 scripts: [],
79 },
80 ],
81 threads: [thread],
82 threadsHydrated: true,
83 runtimeMode: "full-access",
84 };
85}
86
87function makeTerminalStartedEvent(
88 overrides: Partial<TerminalStartedEvent> = {},
89): TerminalStartedEvent {
90 return {
91 type: "started",
92 threadId: "thread-local-1",
93 terminalId: DEFAULT_THREAD_TERMINAL_ID,
94 createdAt: "2026-02-09T00:00:01.000Z",
95 snapshot: {
96 threadId: "thread-local-1",
97 terminalId: DEFAULT_THREAD_TERMINAL_ID,
98 cwd: "/tmp/project",
99 status: "running",
100 pid: 1234,
101 history: "",
102 exitCode: null,
103 exitSignal: null,
104 updatedAt: "2026-02-09T00:00:01.000Z",
105 },
106 ...overrides,
107 };
108}
109
110function makeTerminalActivityEvent(
111 overrides: Partial<TerminalActivityEvent> = {},
112): TerminalActivityEvent {
113 return {
114 type: "activity",
115 threadId: "thread-local-1",
116 terminalId: DEFAULT_THREAD_TERMINAL_ID,
117 createdAt: "2026-02-09T00:00:02.000Z",
118 hasRunningSubprocess: true,
119 ...overrides,
120 };
121}
122
123describe("store reducer thread continuity", () => {
124 it("stores codexThreadId from UPDATE_SESSION", () => {
125 const state = makeState(
126 makeThread({
127 session: null,
128 }),
129 );
130 const next = reducer(state, {
131 type: "UPDATE_SESSION",
132 threadId: "thread-local-1",
133 session: makeSession({ threadId: "thr_123" }),
134 });
135
136 expect(next.threads[0]?.codexThreadId).toBe("thr_123");
137 });
138
139 it("toggles terminal open state per thread", () => {
140 const state = makeState(makeThread({ terminalOpen: false }));
141 const next = reducer(state, {
142 type: "TOGGLE_THREAD_TERMINAL",
143 threadId: "thread-local-1",
144 });
145 expect(next.threads[0]?.terminalOpen).toBe(true);
146 });
147
148 it("sets terminal open state per thread", () => {
149 const state = makeState(makeThread({ terminalOpen: true }));
150 const next = reducer(state, {
151 type: "SET_THREAD_TERMINAL_OPEN",
152 threadId: "thread-local-1",
153 open: false,
154 });
155 expect(next.threads[0]?.terminalOpen).toBe(false);
156 });
157
158 it("sets terminal height per thread", () => {
159 const state = makeState(makeThread({ terminalHeight: 280 }));
160 const next = reducer(state, {
161 type: "SET_THREAD_TERMINAL_HEIGHT",
162 threadId: "thread-local-1",
163 height: 360,
164 });
165 expect(next.threads[0]?.terminalHeight).toBe(360);
166 });
167
168 it("splits the active terminal into side-by-side mode", () => {
169 const state = makeState(makeThread());
170 const next = reducer(state, {
171 type: "SPLIT_THREAD_TERMINAL",
172 threadId: "thread-local-1",
173 terminalId: "term-2",
174 });
175
176 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
177 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
178 expect(next.threads[0]?.terminalGroups).toEqual([
179 {
180 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
181 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
182 },
183 ]);
184 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
185 });
186
187 it("creates a new full-width terminal and switches to tab mode", () => {
188 const state = makeState(makeThread());
189 const next = reducer(state, {
190 type: "NEW_THREAD_TERMINAL",
191 threadId: "thread-local-1",
192 terminalId: "term-2",
193 });
194
195 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
196 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
197 expect(next.threads[0]?.terminalGroups).toEqual([
198 {
199 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
200 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
201 },
202 { id: "group-term-2", terminalIds: ["term-2"] },
203 ]);
204 expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2");
205 });
206
207 it("switches the active terminal and restores its owning group", () => {
208 const state = makeState(
209 makeThread({
210 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
211 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
212 terminalGroups: [
213 {
214 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
215 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
216 },
217 { id: "group-term-3", terminalIds: ["term-3"] },
218 ],
219 activeTerminalGroupId: "group-term-3",
220 }),
221 );
222 const next = reducer(state, {
223 type: "SET_THREAD_ACTIVE_TERMINAL",
224 threadId: "thread-local-1",
225 terminalId: "term-2",
226 });
227
228 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
229 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
230 });
231
232 it("supports splitting beyond two terminals in the same group", () => {
233 const state = makeState(
234 makeThread({
235 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
236 activeTerminalId: "term-2",
237 terminalGroups: [
238 {
239 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
240 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
241 },
242 ],
243 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
244 }),
245 );
246 const next = reducer(state, {
247 type: "SPLIT_THREAD_TERMINAL",
248 threadId: "thread-local-1",
249 terminalId: "term-3",
250 });
251
252 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"]);
253 expect(next.threads[0]?.terminalGroups).toEqual([
254 {
255 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
256 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
257 },
258 ]);
259 expect(next.threads[0]?.activeTerminalId).toBe("term-3");
260 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
261 });
262
263 it("caps split terminals at four per thread", () => {
264 const state = makeState(
265 makeThread({
266 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
267 activeTerminalId: "term-4",
268 terminalGroups: [
269 {
270 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
271 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
272 },
273 ],
274 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
275 }),
276 );
277 const next = reducer(state, {
278 type: "SPLIT_THREAD_TERMINAL",
279 threadId: "thread-local-1",
280 terminalId: "term-5",
281 });
282
283 expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
284 expect(next.threads[0]?.terminalIds).toEqual([
285 DEFAULT_THREAD_TERMINAL_ID,
286 "term-2",
287 "term-3",
288 "term-4",
289 ]);
290 expect(next.threads[0]?.activeTerminalId).toBe("term-4");
291 });
292
293 it("caps new terminals at four per thread", () => {
294 const state = makeState(
295 makeThread({
296 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
297 activeTerminalId: "term-4",
298 terminalGroups: [
299 {
300 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
301 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
302 },
303 { id: "group-term-2", terminalIds: ["term-2"] },
304 { id: "group-term-3", terminalIds: ["term-3"] },
305 { id: "group-term-4", terminalIds: ["term-4"] },
306 ],
307 activeTerminalGroupId: "group-term-4",
308 }),
309 );
310 const next = reducer(state, {
311 type: "NEW_THREAD_TERMINAL",
312 threadId: "thread-local-1",
313 terminalId: "term-5",
314 });
315
316 expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
317 expect(next.threads[0]?.terminalIds).toEqual([
318 DEFAULT_THREAD_TERMINAL_ID,
319 "term-2",
320 "term-3",
321 "term-4",
322 ]);
323 expect(next.threads[0]?.activeTerminalId).toBe("term-4");
324 expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4");
325 });
326
327 it("closes a terminal and keeps grouped layout coherent", () => {
328 const state = makeState(
329 makeThread({
330 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
331 activeTerminalId: "term-2",
332 terminalGroups: [
333 {
334 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
335 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
336 },
337 { id: "group-term-3", terminalIds: ["term-3"] },
338 ],
339 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
340 }),
341 );
342 const next = reducer(state, {
343 type: "CLOSE_THREAD_TERMINAL",
344 threadId: "thread-local-1",
345 terminalId: "term-2",
346 });
347
348 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]);
349 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
350 expect(next.threads[0]?.terminalGroups).toEqual([
351 {
352 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
353 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
354 },
355 { id: "group-term-3", terminalIds: ["term-3"] },
356 ]);
357 });
358
359 it("closes the final terminal and hides the drawer", () => {
360 const state = makeState(
361 makeThread({
362 terminalOpen: true,
363 runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID],
364 }),
365 );
366 const next = reducer(state, {
367 type: "CLOSE_THREAD_TERMINAL",
368 threadId: "thread-local-1",
369 terminalId: DEFAULT_THREAD_TERMINAL_ID,
370 });
371
372 expect(next.threads[0]?.terminalOpen).toBe(false);
373 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
374 expect(next.threads[0]?.runningTerminalIds).toEqual([]);
375 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
376 expect(next.threads[0]?.terminalGroups).toEqual([
377 {
378 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
379 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
380 },
381 ]);
382 });
383
384 it("tracks running terminals from subprocess activity events", () => {
385 const state = makeState(makeThread());
386 const started = reducer(state, {
387 type: "APPLY_TERMINAL_EVENT",
388 event: makeTerminalStartedEvent(),
389 });
390 expect(started.threads[0]?.runningTerminalIds).toEqual([]);
391
392 const active = reducer(started, {
393 type: "APPLY_TERMINAL_EVENT",
394 event: makeTerminalActivityEvent(),
395 });
396 expect(active.threads[0]?.runningTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
397
398 const idle = reducer(active, {
399 type: "APPLY_TERMINAL_EVENT",
400 event: makeTerminalActivityEvent({ hasRunningSubprocess: false }),
401 });
402 expect(idle.threads[0]?.runningTerminalIds).toEqual([]);
403
404 const exited = reducer(active, {
405 type: "APPLY_TERMINAL_EVENT",
406 event: {
407 type: "exited",
408 threadId: "thread-local-1",
409 terminalId: DEFAULT_THREAD_TERMINAL_ID,
410 createdAt: "2026-02-09T00:00:05.000Z",
411 exitCode: 0,
412 exitSignal: null,
413 },
414 });
415 expect(exited.threads[0]?.runningTerminalIds).toEqual([]);
416 });
417
418 it("keeps running status when another terminal in the thread is still running", () => {
419 const state = makeState(
420 makeThread({
421 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
422 runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
423 activeTerminalId: "term-2",
424 terminalGroups: [
425 {
426 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
427 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
428 },
429 ],
430 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
431 }),
432 );
433
434 const next = reducer(state, {
435 type: "APPLY_TERMINAL_EVENT",
436 event: {
437 type: "exited",
438 threadId: "thread-local-1",
439 terminalId: DEFAULT_THREAD_TERMINAL_ID,
440 createdAt: "2026-02-09T00:00:07.000Z",
441 exitCode: 0,
442 exitSignal: null,
443 },
444 });
445
446 expect(next.threads[0]?.runningTerminalIds).toEqual(["term-2"]);
447 });
448
449 it("backfills codexThreadId from routed provider events", () => {
450 const state = makeState(makeThread({ codexThreadId: null }));
451 const next = reducer(state, {
452 type: "APPLY_EVENT",
453 event: makeEvent({
454 method: "thread/started",
455 payload: { thread: { id: "thr_backfilled" } },
456 }),
457 activeAssistantItemRef: { current: null },
458 });
459
460 expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");
461 });
462
463 it("ignores events from a foreign thread within the same session", () => {
464 const state = makeState(makeThread({ codexThreadId: "thr_expected" }));
465 const next = reducer(state, {
466 type: "APPLY_EVENT",
467 event: makeEvent({
468 method: "turn/started",
469 threadId: "thr_unexpected",
470 payload: { turn: { id: "turn-1" } },
471 }),
472 activeAssistantItemRef: { current: null },
473 });
474
475 expect(next).toBe(state);
476 });
477
478 it("rebases thread identity on thread/started during connect", () => {
479 const state = makeState(
480 makeThread({
481 codexThreadId: "thr_old",
482 session: makeSession({
483 status: "connecting",
484 threadId: "thr_old",
485 }),
486 }),
487 );
488 const next = reducer(state, {
489 type: "APPLY_EVENT",
490 event: makeEvent({
491 method: "thread/started",
492 threadId: "thr_new",
493 payload: { thread: { id: "thr_new" } },
494 }),
495 activeAssistantItemRef: { current: null },
496 });
497
498 expect(next.threads[0]?.codexThreadId).toBe("thr_new");
499 expect(next.threads[0]?.session?.threadId).toBe("thr_new");
500 });
501
502 it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => {
503 const state = makeState(
504 makeThread({
505 events: [],
506 turnDiffSummaries: [
507 {
508 turnId: "turn-1",
509 completedAt: "2026-02-09T00:00:01.000Z",
510 files: [{ path: "src/existing.ts", kind: "modified" }],
511 },
512 ],
513 }),
514 );
515
516 const withFileChange = reducer(state, {
517 type: "APPLY_EVENT",
518 event: makeEvent({
519 method: "item/completed",
520 turnId: "turn-2",
521 createdAt: "2026-02-09T00:00:02.000Z",
522 payload: {
523 item: {
524 type: "fileChange",
525 changes: [{ path: "src/new.ts", kind: "added" }],
526 },
527 },
528 }),
529 activeAssistantItemRef: { current: null },
530 });
531
532 expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
533 "turn-1",
534 ]);
535
536 const completed = reducer(withFileChange, {
537 type: "APPLY_EVENT",
538 event: makeEvent({
539 method: "turn/completed",
540 turnId: "turn-2",
541 createdAt: "2026-02-09T00:00:03.000Z",
542 payload: { turn: { id: "turn-2", status: "completed" } },
543 }),
544 activeAssistantItemRef: { current: null },
545 });
546
547 expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([
548 "turn-2",
549 "turn-1",
550 ]);
551 });
552
553 it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => {
554 const state = makeState(makeThread({ turnDiffSummaries: [] }));
555 const next = reducer(state, {
556 type: "APPLY_EVENT",
557 event: makeEvent({
558 method: "turn/completed",
559 turnId: "turn-1",
560 createdAt: "2026-02-09T00:00:03.000Z",
561 payload: { turn: { id: "turn-1", status: "completed" } },
562 }),
563 activeAssistantItemRef: { current: null },
564 });
565
566 expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1");
567 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1);
568 });
569
570 it("reconciles project ids by cwd when syncing backend projects", () => {
571 const state: AppState = {
572 projects: [
573 {
574 id: "project-old-a",
575 name: "A",
576 cwd: "/tmp/a",
577 model: "gpt-5.3-codex",
578 expanded: false,
579 scripts: [],
580 },
581 {
582 id: "project-old-b",
583 name: "B",
584 cwd: "/tmp/b",
585 model: "gpt-5.3-codex",
586 expanded: true,
587 scripts: [],
588 },
589 ],
590 threads: [
591 makeThread({
592 id: "thread-a",
593 projectId: "project-old-a",
594 }),
595 makeThread({
596 id: "thread-b",
597 projectId: "project-old-b",
598 }),
599 ],
600 threadsHydrated: true,
601 runtimeMode: "full-access",
602 };
603
604 const next = reducer(state, {
605 type: "SYNC_PROJECTS",
606 projects: [
607 {
608 id: "project-new-a",
609 name: "A",
610 cwd: "/tmp/a",
611 model: "gpt-5.3-codex",
612 expanded: true,
613 scripts: [],
614 },
615 ],
616 });
617
618 expect(next.projects).toHaveLength(1);
619 expect(next.projects[0]?.id).toBe("project-new-a");
620 // Preserve existing project UI preferences by cwd
621 expect(next.projects[0]?.expanded).toBe(false);
622 expect(next.threads).toHaveLength(1);
623 expect(next.threads[0]?.id).toBe("thread-a");
624 expect(next.threads[0]?.projectId).toBe("project-new-a");
625 });
626
627 it("treats empty scripts from sync as authoritative", () => {
628 const state: AppState = {
629 projects: [
630 {
631 id: "project-old-a",
632 name: "A",
633 cwd: "/tmp/a",
634 model: "gpt-5.3-codex",
635 expanded: true,
636 scripts: [
637 {
638 id: "test",
639 name: "Test",
640 command: "bun test",
641 icon: "test",
642 runOnWorktreeCreate: false,
643 },
644 ],
645 },
646 ],
647 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
648 threadsHydrated: true,
649 runtimeMode: "full-access",
650 };
651
652 const next = reducer(state, {
653 type: "SYNC_PROJECTS",
654 projects: [
655 {
656 id: "project-new-a",
657 name: "A",
658 cwd: "/tmp/a",
659 model: "gpt-5.3-codex",
660 expanded: false,
661 scripts: [],
662 },
663 ],
664 });
665
666 expect(next.projects[0]?.scripts).toEqual([]);
667 });
668
669 it("updates project scripts", () => {
670 const state = makeState(makeThread());
671 const next = reducer(state, {
672 type: "SET_PROJECT_SCRIPTS",
673 projectId: "project-1",
674 scripts: [
675 {
676 id: "test",
677 name: "Test",
678 command: "bun test",
679 icon: "test",
680 runOnWorktreeCreate: false,
681 },
682 ],
683 });
684
685 expect(next.projects[0]?.scripts).toEqual([
686 {
687 id: "test",
688 name: "Test",
689 command: "bun test",
690 icon: "test",
691 runOnWorktreeCreate: false,
692 },
693 ]);
694 });
695
696 it("marks threads hydration state", () => {
697 const state = makeState(makeThread());
698 const next = reducer(state, {
699 type: "SET_THREADS_HYDRATED",
700 hydrated: false,
701 });
702
703 expect(next.threadsHydrated).toBe(false);
704 });
705
706 it("deletes a project and all of its threads", () => {
707 const state: AppState = {
708 projects: [
709 {
710 id: "project-1",
711 name: "One",
712 cwd: "/tmp/one",
713 model: "gpt-5.3-codex",
714 expanded: true,
715 scripts: [],
716 },
717 {
718 id: "project-2",
719 name: "Two",
720 cwd: "/tmp/two",
721 model: "gpt-5.3-codex",
722 expanded: true,
723 scripts: [],
724 },
725 ],
726 threads: [
727 makeThread({
728 id: "thread-a",
729 projectId: "project-1",
730 }),
731 makeThread({
732 id: "thread-b",
733 projectId: "project-2",
734 }),
735 ],
736 threadsHydrated: true,
737 runtimeMode: "full-access",
738 };
739
740 const next = reducer(state, {
741 type: "DELETE_PROJECT",
742 projectId: "project-1",
743 });
744
745 expect(next.projects).toHaveLength(1);
746 expect(next.projects[0]?.id).toBe("project-2");
747 expect(next.threads).toHaveLength(1);
748 expect(next.threads[0]?.id).toBe("thread-b");
749 });
750
751 it("marks completion as seen immediately for the active thread", () => {
752 const state = makeState(
753 makeThread({
754 session: makeSession({
755 status: "running",
756 activeTurnId: "turn-1",
757 }),
758 lastVisitedAt: "2026-02-08T10:00:00.000Z",
759 }),
760 );
761
762 const completedAt = "2026-02-08T10:00:10.000Z";
763 const next = reducer(state, {
764 type: "APPLY_EVENT",
765 event: makeEvent({
766 method: "turn/completed",
767 turnId: "turn-1",
768 createdAt: completedAt,
769 payload: { turn: { id: "turn-1", status: "completed" } },
770 }),
771 activeAssistantItemRef: { current: null },
772 activeThreadId: "thread-local-1",
773 });
774
775 expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt);
776 expect(next.threads[0]?.lastVisitedAt).toBe(completedAt);
777 });
778
779 it("marks a thread as visited when selected", () => {
780 const state = makeState(
781 makeThread({
782 latestTurnCompletedAt: "2026-02-08T10:00:10.000Z",
783 lastVisitedAt: "2026-02-08T10:00:00.000Z",
784 }),
785 );
786
787 const nextVisitedAt = "2026-02-08T10:00:20.000Z";
788 const next = reducer(state, {
789 type: "MARK_THREAD_VISITED",
790 threadId: "thread-local-1",
791 visitedAt: nextVisitedAt,
792 });
793
794 expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt);
795 });
796
797 it("reverts thread state to a checkpoint snapshot", () => {
798 const state = makeState(
799 makeThread({
800 codexThreadId: "thr_before",
801 session: makeSession({
802 status: "running",
803 threadId: "thr_before",
804 activeTurnId: "turn-live",
805 }),
806 messages: [
807 {
808 id: "m-1",
809 role: "user",
810 text: "First",
811 createdAt: "2026-02-08T10:00:00.000Z",
812 streaming: false,
813 },
814 {
815 id: "m-2",
816 role: "assistant",
817 text: "First reply",
818 createdAt: "2026-02-08T10:00:01.000Z",
819 streaming: false,
820 },
821 {
822 id: "m-3",
823 role: "user",
824 text: "Second",
825 createdAt: "2026-02-08T10:00:02.000Z",
826 streaming: false,
827 },
828 ],
829 events: [
830 makeEvent({
831 method: "turn/started",
832 turnId: "turn-live",
833 }),
834 ],
835 turnDiffSummaries: [
836 {
837 turnId: "turn_1",
838 completedAt: "2026-02-08T10:00:01.000Z",
839 files: [{ path: "src/first.ts", kind: "modified" }],
840 checkpointTurnCount: 1,
841 },
842 {
843 turnId: "turn_2",
844 completedAt: "2026-02-08T10:00:03.000Z",
845 files: [{ path: "src/second.ts", kind: "modified" }],
846 checkpointTurnCount: 2,
847 },
848 ],
849 error: "temporary failure",
850 latestTurnId: "turn-live",
851 latestTurnStartedAt: "2026-02-08T10:00:03.000Z",
852 }),
853 );
854
855 const next = reducer(state, {
856 type: "REVERT_TO_CHECKPOINT",
857 threadId: "thread-local-1",
858 sessionId: "sess-1",
859 threadRuntimeId: "thr_after",
860 turnCount: 1,
861 messageCount: 2,
862 });
863
864 expect(next.threads[0]?.codexThreadId).toBe("thr_after");
865 expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]);
866 expect(next.threads[0]?.events).toEqual([]);
867 expect(next.threads[0]?.error).toBeNull();
868 expect(next.threads[0]?.session?.status).toBe("ready");
869 expect(next.threads[0]?.session?.activeTurnId).toBeUndefined();
870 expect(next.threads[0]?.session?.threadId).toBe("thr_after");
871 expect(next.threads[0]?.latestTurnId).toBeUndefined();
872 expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]);
873 });
874
875 it("keeps existing turn file metadata when later events for the same turn arrive", () => {
876 const state = makeState(
877 makeThread({
878 turnDiffSummaries: [
879 {
880 turnId: "turn-1",
881 completedAt: "2026-02-09T00:00:03.000Z",
882 files: [
883 {
884 path: "src/from-checkpoint.ts",
885 additions: 1,
886 deletions: 1,
887 },
888 ],
889 },
890 ],
891 }),
892 );
893
894 const next = reducer(state, {
895 type: "APPLY_EVENT",
896 event: makeEvent({
897 method: "turn/completed",
898 turnId: "turn-1",
899 createdAt: "2026-02-09T00:00:04.000Z",
900 payload: { turn: { id: "turn-1", status: "completed" } },
901 }),
902 activeAssistantItemRef: { current: null },
903 });
904
905 expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([
906 "src/from-checkpoint.ts",
907 ]);
908 expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1);
909 expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1);
910 });
911
912 it("updates checkpoint turn counts from authoritative checkpoint mappings", () => {
913 const state = makeState(
914 makeThread({
915 turnDiffSummaries: [
916 {
917 turnId: "turn-1",
918 completedAt: "2026-02-09T00:00:03.000Z",
919 files: [],
920 checkpointTurnCount: 1,
921 },
922 {
923 turnId: "turn-2",
924 completedAt: "2026-02-09T00:00:04.000Z",
925 files: [],
926 checkpointTurnCount: 2,
927 },
928 ],
929 }),
930 );
931
932 const next = reducer(state, {
933 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS",
934 threadId: "thread-local-1",
935 checkpointTurnCountByTurnId: {
936 "turn-1": 2,
937 "turn-2": 3,
938 },
939 });
940
941 expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2);
942 expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3);
943 });
944
945 it("does not rederive completed turn summaries from late turn diff events", () => {
946 const completed = reducer(makeState(makeThread()), {
947 type: "APPLY_EVENT",
948 event: makeEvent({
949 method: "turn/completed",
950 turnId: "turn-1",
951 createdAt: "2026-02-09T00:00:03.000Z",
952 payload: { turn: { id: "turn-1", status: "completed" } },
953 }),
954 activeAssistantItemRef: { current: null },
955 });
956
957 expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]);
958
959 const withLateDiff = reducer(completed, {
960 type: "APPLY_EVENT",
961 event: makeEvent({
962 method: "turn/diff/updated",
963 turnId: "turn-1",
964 createdAt: "2026-02-09T00:00:04.000Z",
965 payload: {
966 diff: [
967 "diff --git a/src/a.ts b/src/a.ts",
968 "@@ -1 +1 @@",
969 "-old",
970 "+new",
971 "diff --git a/src/b.ts b/src/b.ts",
972 "@@ -1 +1 @@",
973 "-old",
974 "+new",
975 ].join("\n"),
976 },
977 }),
978 activeAssistantItemRef: { current: null },
979 });
980
981 expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]);
982 });
983});
apps/web/src/store.ts+99-494
......@@ -8,19 +8,13 @@ import {
88 useReducer,
99} from "react";
1010
11import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
1411import {
15 applyEventToMessages,
16 asObject,
17 asString,
18 deriveTurnDiffSummaries,
19 inferCheckpointTurnCountByTurnId,
20 evolveSession,
21} from "./session-logic";
12 type OrchestrationReadModel,
13 normalizeProjectScripts,
14} from "@t3tools/contracts";
15import { resolveModelSlug } from "./model-logic";
2216import {
23 type ChatAttachment,
17 DEFAULT_THREAD_TERMINAL_HEIGHT,
2418 DEFAULT_THREAD_TERMINAL_ID,
2519 DEFAULT_RUNTIME_MODE,
2620 MAX_THREAD_TERMINAL_COUNT,
......@@ -34,13 +28,10 @@ import {
3428// ── Actions ──────────────────────────────────────────────────────────
3529
3630type Action =
37 | { type: "ADD_PROJECT"; project: Project }
31 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
3832 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
39 | { type: "SYNC_PROJECTS"; projects: Project[] }
4033 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
4134 | { type: "TOGGLE_PROJECT"; projectId: string }
42 | { type: "DELETE_PROJECT"; projectId: string }
43 | { type: "ADD_THREAD"; thread: Thread }
4435 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string }
4536 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean }
4637 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number }
......@@ -48,46 +39,14 @@ type Action =
4839 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
4940 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
5041 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
51 | {
52 type: "APPLY_EVENT";
53 event: ProviderEvent;
54 activeAssistantItemRef: { current: string | null };
55 activeThreadId?: string | null;
56 }
57 | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent }
58 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
59 | {
60 type: "PUSH_USER_MESSAGE";
61 threadId: string;
62 id: string;
63 text: string;
64 attachments?: ChatAttachment[];
65 }
6642 | { type: "SET_ERROR"; threadId: string; error: string | null }
67 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
68 | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
69 | {
70 type: "REVERT_TO_CHECKPOINT";
71 threadId: string;
72 sessionId: string;
73 threadRuntimeId: string;
74 turnCount: number;
75 messageCount: number;
76 }
77 | {
78 type: "SET_THREAD_TURN_CHECKPOINT_COUNTS";
79 threadId: string;
80 checkpointTurnCountByTurnId: Record<string, number>;
81 }
8243 | {
8344 type: "SET_THREAD_BRANCH";
8445 threadId: string;
8546 branch: string | null;
8647 worktreePath: string | null;
8748 }
88 | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string }
89 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }
90 | { type: "DELETE_THREAD"; threadId: string };
49 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
9150
9251// ── State ────────────────────────────────────────────────────────────
9352
......@@ -116,6 +75,7 @@ const initialState: AppState = {
11675 threadsHydrated: false,
11776 runtimeMode: DEFAULT_RUNTIME_MODE,
11877};
78const persistedExpandedProjectCwds = new Set<string>();
11979
12080// ── Helpers ──────────────────────────────────────────────────────────
12181
......@@ -123,21 +83,24 @@ function readPersistedState(): AppState {
12383 if (typeof window === "undefined") return initialState;
12484
12585 try {
126 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
127 const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) => window.localStorage.getItem(key));
128 const rawLegacy = legacyValues.find((value) => value !== null) ?? null;
129 const raw = rawCurrent ?? rawLegacy;
86 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
13087 if (!raw) return initialState;
131 const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1");
132 const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1);
133 if (!hydrated) return initialState;
134
135 const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));
136
88 const parsed = JSON.parse(raw) as {
89 runtimeMode?: RuntimeMode;
90 expandedProjectCwds?: string[];
91 };
92 persistedExpandedProjectCwds.clear();
93 for (const cwd of parsed.expandedProjectCwds ?? []) {
94 if (typeof cwd === "string" && cwd.length > 0) {
95 persistedExpandedProjectCwds.add(cwd);
96 }
97 }
13798 return {
138 ...hydrated,
139 threads,
140 threadsHydrated: threads.length > 0,
99 ...initialState,
100 runtimeMode:
101 parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"
102 ? parsed.runtimeMode
103 : DEFAULT_RUNTIME_MODE,
141104 };
142105 } catch {
143106 return initialState;
......@@ -148,7 +111,15 @@ function persistState(state: AppState): void {
148111 if (typeof window === "undefined") return;
149112
150113 try {
151 window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state)));
114 window.localStorage.setItem(
115 PERSISTED_STATE_KEY,
116 JSON.stringify({
117 runtimeMode: state.runtimeMode,
118 expandedProjectCwds: state.projects
119 .filter((project) => project.expanded)
120 .map((project) => project.cwd),
121 }),
122 );
152123 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
153124 window.localStorage.removeItem(legacyKey);
154125 }
......@@ -165,63 +136,6 @@ function updateThread(
165136 return threads.map((t) => (t.id === threadId ? updater(t) : t));
166137}
167138
168function mergeTurnDiffSummaries(
169 existing: Thread["turnDiffSummaries"],
170 next: Thread["turnDiffSummaries"],
171): Thread["turnDiffSummaries"] {
172 if (next.length === 0) return existing;
173
174 const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const));
175 const merged = next.map((summary) => {
176 const previous = existingByTurnId.get(summary.turnId);
177 if (!previous) {
178 return summary;
179 }
180
181 const files =
182 summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files;
183
184 return {
185 ...summary,
186 files,
187 ...(summary.assistantMessageId
188 ? {}
189 : previous.assistantMessageId
190 ? { assistantMessageId: previous.assistantMessageId }
191 : {}),
192 ...(typeof summary.checkpointTurnCount === "number"
193 ? {}
194 : typeof previous.checkpointTurnCount === "number"
195 ? { checkpointTurnCount: previous.checkpointTurnCount }
196 : {}),
197 };
198 });
199
200 const mergedTurnIds = new Set(merged.map((summary) => summary.turnId));
201 for (const summary of existing) {
202 if (!mergedTurnIds.has(summary.turnId)) {
203 merged.push(summary);
204 }
205 }
206
207 const sorted = merged.toSorted((a, b) => {
208 const aTime = Date.parse(a.completedAt);
209 const bTime = Date.parse(b.completedAt);
210 if (Number.isNaN(aTime) || Number.isNaN(bTime)) {
211 return b.completedAt.localeCompare(a.completedAt);
212 }
213 return bTime - aTime;
214 });
215
216 const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted);
217 return sorted.map((summary) =>
218 typeof summary.checkpointTurnCount === "number"
219 ? summary
220 : Object.assign({}, summary, {
221 checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId],
222 }),
223 );
224}
225139
226140function normalizeTerminalIds(terminalIds: string[]): string[] {
227141 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
......@@ -409,108 +323,80 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
409323 });
410324}
411325
412function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
413 return threads.find((t) => t.session?.sessionId === sessionId);
414}
415
416function getEventTurnId(event: ProviderEvent): string | undefined {
417 if (event.turnId) return event.turnId;
418 const payload = asObject(event.payload);
419 const turn = asObject(payload?.turn);
420 return asString(turn?.id);
421}
422
423function getEventThreadId(event: ProviderEvent): string | undefined {
424 if (event.threadId) return event.threadId;
425 const payload = asObject(event.payload);
426 const payloadThread = asObject(payload?.thread);
427 const payloadMessage = asObject(payload?.msg);
428 return (
429 asString(payload?.threadId) ??
430 asString(payloadThread?.id) ??
431 asString(payload?.conversationId) ??
432 asString(payload?.thread_id) ??
433 asString(payloadMessage?.thread_id)
434 );
435}
436
437function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean {
438 const eventThreadId = getEventThreadId(event);
439 if (!eventThreadId) {
440 return false;
441 }
442
443 const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId;
444 if (!expectedThreadId || eventThreadId === expectedThreadId) {
445 return false;
446 }
447
448 // During connect, accept a thread/started notification as an identity rebind.
449 if (event.method === "thread/started" && thread.session?.status === "connecting") {
450 return false;
451 }
452
453 return true;
454}
455
456function durationMs(startIso: string, endIso: string): number | undefined {
457 const start = Date.parse(startIso);
458 const end = Date.parse(endIso);
459 if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
460 return undefined;
461 }
462
463 return end - start;
464}
465
466function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> {
467 if (event.method === "turn/started") {
468 return {
469 latestTurnId: getEventTurnId(event) ?? thread.latestTurnId,
470 latestTurnStartedAt: event.createdAt,
471 latestTurnCompletedAt: undefined,
472 latestTurnDurationMs: undefined,
473 };
474 }
475
476 if (event.method === "turn/completed") {
477 const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId;
478 const startedAt =
479 completedTurnId && completedTurnId === thread.latestTurnId
480 ? thread.latestTurnStartedAt
481 : undefined;
482 const elapsed =
483 startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined;
484
485 return {
486 latestTurnId: completedTurnId ?? thread.latestTurnId,
487 latestTurnCompletedAt: event.createdAt,
488 latestTurnDurationMs: elapsed,
489 };
490 }
491
492 return {};
493}
494326
495327// ── Reducer ──────────────────────────────────────────────────────────
496328
497329export function reducer(state: AppState, action: Action): AppState {
498330 switch (action.type) {
499 case "ADD_PROJECT":
500 if (state.projects.some((project) => project.cwd === action.project.cwd)) {
501 return state;
502 }
331 case "SYNC_SERVER_READ_MODEL": {
332 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));
333 const projects = action.readModel.projects.map((project) => {
334 const existing = state.projects.find((entry) => entry.id === project.id);
335 return {
336 id: project.id,
337 name: project.name,
338 cwd: project.cwd,
339 model: resolveModelSlug(project.model),
340 expanded:
341 existing?.expanded ??
342 (persistedExpandedProjectCwds.size > 0
343 ? persistedExpandedProjectCwds.has(project.cwd)
344 : true),
345 scripts: existing?.scripts ?? [],
346 };
347 });
348 const threads = action.readModel.threads.map((thread) => {
349 const existing = existingThreadById.get(thread.id);
350 return normalizeThreadTerminals({
351 id: thread.id,
352 codexThreadId: thread.session?.threadId ?? null,
353 projectId: thread.projectId,
354 title: thread.title,
355 model: resolveModelSlug(thread.model),
356 terminalOpen: existing?.terminalOpen ?? false,
357 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
358 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
359 runningTerminalIds: existing?.runningTerminalIds ?? [],
360 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
361 terminalGroups: existing?.terminalGroups ?? [
362 { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
363 ],
364 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
365 session: thread.session
366 ? {
367 sessionId: thread.session.sessionId,
368 provider: thread.session.provider,
369 status: thread.session.status,
370 threadId: thread.session.threadId,
371 activeTurnId: thread.session.activeTurnId ?? undefined,
372 createdAt: thread.session.createdAt,
373 updatedAt: thread.session.updatedAt,
374 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
375 }
376 : null,
377 messages: thread.messages.map((message) => ({ ...message })),
378 error: thread.error,
379 createdAt: thread.createdAt,
380 latestTurnId: thread.latestTurnId ?? undefined,
381 latestTurnStartedAt: thread.latestTurnStartedAt ?? undefined,
382 latestTurnCompletedAt: thread.latestTurnCompletedAt ?? undefined,
383 latestTurnDurationMs: thread.latestTurnDurationMs ?? undefined,
384 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
385 branch: thread.branch,
386 worktreePath: thread.worktreePath,
387 turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
388 ...summary,
389 files: summary.files.map((file) => ({ ...file })),
390 })),
391 });
392 });
503393 return {
504394 ...state,
505 projects: [
506 ...state.projects,
507 {
508 ...action.project,
509 model: resolveModelSlug(action.project.model),
510 scripts: normalizeProjectScripts(action.project.scripts),
511 },
512 ],
395 projects,
396 threads,
397 threadsHydrated: true,
513398 };
399 }
514400
515401 case "SET_PROJECT_SCRIPTS":
516402 return {
......@@ -531,46 +417,6 @@ export function reducer(state: AppState, action: Action): AppState {
531417 threadsHydrated: action.hydrated,
532418 };
533419
534 case "SYNC_PROJECTS": {
535 const previousByCwd = new Map(
536 state.projects.map((project) => [project.cwd, project] as const),
537 );
538 const nextProjects = action.projects.map((project) => {
539 const previous = previousByCwd.get(project.cwd);
540 const scripts = normalizeProjectScripts(project.scripts);
541 return {
542 ...project,
543 model: resolveModelSlug(previous?.model ?? project.model),
544 expanded: previous?.expanded ?? project.expanded,
545 scripts,
546 };
547 });
548 const previousProjectById = new Map(
549 state.projects.map((project) => [project.id, project] as const),
550 );
551 const nextProjectIdByCwd = new Map(
552 nextProjects.map((project) => [project.cwd, project.id] as const),
553 );
554 const nextThreads = state.threads
555 .map((thread) => {
556 const previousProject = previousProjectById.get(thread.projectId);
557 if (!previousProject) return null;
558 const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd);
559 if (!mappedProjectId) return null;
560 return normalizeThreadTerminals({
561 ...thread,
562 projectId: mappedProjectId,
563 });
564 })
565 .filter((thread): thread is Thread => thread !== null);
566
567 return {
568 ...state,
569 projects: nextProjects,
570 threads: nextThreads,
571 };
572 }
573
574420 case "TOGGLE_PROJECT":
575421 return {
576422 ...state,
......@@ -579,34 +425,6 @@ export function reducer(state: AppState, action: Action): AppState {
579425 ),
580426 };
581427
582 case "DELETE_PROJECT": {
583 const projects = state.projects.filter((project) => project.id !== action.projectId);
584 if (projects.length === state.projects.length) {
585 return state;
586 }
587
588 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
589
590 return {
591 ...state,
592 projects,
593 threads,
594 };
595 }
596
597 case "ADD_THREAD": {
598 const nextThread = normalizeThreadTerminals({
599 ...action.thread,
600 model: resolveModelSlug(action.thread.model),
601 lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt,
602 turnDiffSummaries: action.thread.turnDiffSummaries ?? [],
603 });
604 return {
605 ...state,
606 threads: [...state.threads, nextThread],
607 };
608 }
609
610428 case "TOGGLE_THREAD_TERMINAL":
611429 return {
612430 ...state,
......@@ -774,118 +592,6 @@ export function reducer(state: AppState, action: Action): AppState {
774592 ),
775593 };
776594
777 case "APPLY_TERMINAL_EVENT":
778 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {
779 return state;
780 }
781 return {
782 ...state,
783 threads: updateThread(state.threads, action.event.threadId, (thread) => {
784 const normalizedThread = normalizeThreadTerminals(thread);
785 const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds);
786 if (action.event.type === "started" || action.event.type === "restarted") {
787 runningTerminalIdSet.delete(action.event.terminalId);
788 } else if (action.event.type === "activity") {
789 if (action.event.hasRunningSubprocess) {
790 runningTerminalIdSet.add(action.event.terminalId);
791 } else {
792 runningTerminalIdSet.delete(action.event.terminalId);
793 }
794 } else if (action.event.type === "exited" || action.event.type === "error") {
795 runningTerminalIdSet.delete(action.event.terminalId);
796 }
797
798 return normalizeThreadTerminals({
799 ...normalizedThread,
800 runningTerminalIds: [...runningTerminalIdSet],
801 });
802 }),
803 };
804
805 case "APPLY_EVENT": {
806 const { event, activeAssistantItemRef, activeThreadId } = action;
807 const target = findThreadBySessionId(state.threads, event.sessionId);
808 if (!target) return state;
809 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
810
811 return {
812 ...state,
813 threads: updateThread(state.threads, target.id, (t) => {
814 const nextEvents = [event, ...t.events];
815 const eventTurnId = getEventTurnId(event);
816 const hasCompletedSummaryForTurn = Boolean(
817 eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId),
818 );
819 const itemType = asString(asObject(asObject(event.payload)?.item)?.type);
820 const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase();
821 const isMetadataItemCompleted =
822 event.method === "item/completed" &&
823 (normalizedItemType === "agentmessage" || normalizedItemType === "filechange");
824 const shouldRederiveDiffs =
825 event.method === "turn/completed" ||
826 (hasCompletedSummaryForTurn && isMetadataItemCompleted);
827 const turnDiffSummaries = shouldRederiveDiffs
828 ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents))
829 : t.turnDiffSummaries;
830 const eventThreadId = getEventThreadId(event);
831 const shouldRebindIdentity =
832 event.method === "thread/started" && t.session?.status === "connecting";
833 return {
834 ...t,
835 codexThreadId: shouldRebindIdentity
836 ? (eventThreadId ?? t.codexThreadId)
837 : (t.codexThreadId ?? eventThreadId ?? null),
838 error: event.kind === "error" && event.message ? event.message : t.error,
839 session: t.session ? evolveSession(t.session, event) : t.session,
840 messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
841 events: nextEvents,
842 turnDiffSummaries,
843 ...updateTurnFields(t, event),
844 ...(event.method === "turn/completed" && t.id === activeThreadId
845 ? { lastVisitedAt: event.createdAt }
846 : {}),
847 };
848 }),
849 };
850 }
851
852 case "UPDATE_SESSION":
853 return {
854 ...state,
855 threads: updateThread(state.threads, action.threadId, (t) => ({
856 ...t,
857 session: action.session,
858 codexThreadId: action.session.threadId ?? t.codexThreadId,
859 events: [],
860 error: null,
861 latestTurnId: undefined,
862 latestTurnStartedAt: undefined,
863 latestTurnCompletedAt: undefined,
864 latestTurnDurationMs: undefined,
865 })),
866 };
867
868 case "PUSH_USER_MESSAGE":
869 return {
870 ...state,
871 threads: updateThread(state.threads, action.threadId, (t) => ({
872 ...t,
873 messages: [
874 ...t.messages,
875 {
876 id: action.id,
877 role: "user" as const,
878 text: action.text,
879 ...(action.attachments && action.attachments.length > 0
880 ? { attachments: action.attachments }
881 : {}),
882 createdAt: new Date().toISOString(),
883 streaming: false,
884 },
885 ],
886 })),
887 };
888
889595 case "SET_ERROR":
890596 return {
891597 ...state,
......@@ -895,90 +601,6 @@ export function reducer(state: AppState, action: Action): AppState {
895601 })),
896602 };
897603
898 case "SET_THREAD_TITLE":
899 return {
900 ...state,
901 threads: updateThread(state.threads, action.threadId, (t) => ({
902 ...t,
903 title: action.title,
904 })),
905 };
906
907 case "SET_THREAD_MODEL":
908 return {
909 ...state,
910 threads: updateThread(state.threads, action.threadId, (t) => ({
911 ...t,
912 model: resolveModelSlug(action.model),
913 })),
914 };
915
916 case "REVERT_TO_CHECKPOINT":
917 return {
918 ...state,
919 threads: updateThread(state.threads, action.threadId, (t) => {
920 const nextMessageCount = Math.max(0, Math.floor(action.messageCount));
921 const nextTurnCount = Math.max(0, Math.floor(action.turnCount));
922 const now = new Date().toISOString();
923 return {
924 ...t,
925 codexThreadId: action.threadRuntimeId,
926 session:
927 t.session?.sessionId === action.sessionId
928 ? {
929 ...t.session,
930 status: "ready",
931 threadId: action.threadRuntimeId,
932 activeTurnId: undefined,
933 updatedAt: now,
934 lastError: undefined,
935 }
936 : t.session,
937 messages: t.messages.slice(0, nextMessageCount),
938 events: [],
939 turnDiffSummaries: t.turnDiffSummaries.filter(
940 (summary) =>
941 typeof summary.checkpointTurnCount === "number" &&
942 summary.checkpointTurnCount <= nextTurnCount,
943 ),
944 error: null,
945 latestTurnId: undefined,
946 latestTurnStartedAt: undefined,
947 latestTurnCompletedAt: undefined,
948 latestTurnDurationMs: undefined,
949 lastVisitedAt: now,
950 };
951 }),
952 };
953
954 case "SET_THREAD_TURN_CHECKPOINT_COUNTS":
955 return {
956 ...state,
957 threads: updateThread(state.threads, action.threadId, (t) => {
958 const hasUpdates = t.turnDiffSummaries.some(
959 (summary) =>
960 action.checkpointTurnCountByTurnId[summary.turnId] !== undefined &&
961 action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount,
962 );
963 if (!hasUpdates) {
964 return t;
965 }
966 return {
967 ...t,
968 turnDiffSummaries: t.turnDiffSummaries.map((summary) => {
969 const turnCount = action.checkpointTurnCountByTurnId[summary.turnId];
970 if (turnCount === undefined) {
971 return summary;
972 }
973 return {
974 ...summary,
975 checkpointTurnCount: turnCount,
976 };
977 }),
978 };
979 }),
980 };
981
982604 case "SET_THREAD_BRANCH": {
983605 return {
984606 ...state,
......@@ -997,29 +619,12 @@ export function reducer(state: AppState, action: Action): AppState {
997619 };
998620 }
999621
1000 case "MARK_THREAD_VISITED": {
1001 const visitedAt = action.visitedAt ?? new Date().toISOString();
1002 return {
1003 ...state,
1004 threads: updateThread(state.threads, action.threadId, (t) => ({
1005 ...t,
1006 lastVisitedAt: visitedAt,
1007 })),
1008 };
1009 }
1010
1011622 case "SET_RUNTIME_MODE":
1012623 return {
1013624 ...state,
1014625 runtimeMode: action.mode,
1015626 };
1016627
1017 case "DELETE_THREAD":
1018 return {
1019 ...state,
1020 threads: state.threads.filter((t) => t.id !== action.threadId),
1021 };
1022
1023628 default:
1024629 return state;
1025630 }
apps/web/src/threadFactory.ts-1
......@@ -32,7 +32,6 @@ export function createThread(projectId: string, options: CreateThreadOptions = {
3232 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
3333 session: null,
3434 messages: [],
35 events: [],
3635 turnDiffSummaries: [],
3736 error: null,
3837 createdAt,
apps/web/src/types.ts-2
......@@ -1,6 +1,5 @@
11import type {
22 ProjectScript as ContractProjectScript,
3 ProviderEvent,
43 ProviderSession,
54} from "@t3tools/contracts";
65
......@@ -77,7 +76,6 @@ export interface Thread {
7776 activeTerminalGroupId: string;
7877 session: ProviderSession | null;
7978 messages: ChatMessage[];
80 events: ProviderEvent[];
8179 error: string | null;
8280 createdAt: string;
8381 latestTurnId?: string | undefined;
apps/web/src/worktreeCleanup.test.ts-1
......@@ -24,7 +24,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2424 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
2525 session: null,
2626 messages: [],
27 events: [],
2827 turnDiffSummaries: [],
2928 error: null,
3029 createdAt: "2026-02-13T00:00:00.000Z",
apps/web/src/wsNativeApi.ts+30-4
......@@ -1,4 +1,13 @@
1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
1import {
2 ORCHESTRATION_WS_CHANNELS,
3 ORCHESTRATION_WS_METHODS,
4 type NativeApi,
5 WS_CHANNELS,
6 WS_METHODS,
7 type OrchestrationEvent,
8 type OrchestrationReadModel,
9 type WsWelcomePayload,
10} from "@t3tools/contracts";
211
312import { showContextMenuFallback } from "./contextMenuFallback";
413import { WsTransport } from "./wsTransport";
......@@ -90,13 +99,10 @@ export function createWsNativeApi(): NativeApi {
9099 interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input),
91100 respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input),
92101 stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
93 listSessions: () => transport.request(WS_METHODS.providersListSessions),
94102 listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input),
95103 getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input),
96104 revertToCheckpoint: (input) =>
97105 transport.request(WS_METHODS.providersRevertToCheckpoint, input),
98 onEvent: (callback) =>
99 transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void),
100106 },
101107 projects: {
102108 list: () => transport.request(WS_METHODS.projectsList),
......@@ -149,6 +155,26 @@ export function createWsNativeApi(): NativeApi {
149155 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
150156 upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input),
151157 },
158 orchestration: {
159 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
160 dispatchCommand: (command) =>
161 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command),
162 replayEvents: (fromSequenceExclusive) =>
163 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
164 onReadModel: (callback) =>
165 transport.subscribe(
166 ORCHESTRATION_WS_CHANNELS.readModel,
167 (data: unknown) => {
168 const payload = data as { snapshot: OrchestrationReadModel };
169 callback(payload.snapshot);
170 },
171 ),
172 onDomainEvent: (callback) =>
173 transport.subscribe(
174 ORCHESTRATION_WS_CHANNELS.domainEvent,
175 callback as (data: unknown) => void,
176 ),
177 },
152178 };
153179
154180 instance = { api, transport };
bun.lock+74
......@@ -34,6 +34,8 @@
3434 "t3": "./dist/index.mjs",
3535 },
3636 "dependencies": {
37 "better-sqlite3": "^12.6.2",
38 "effect": "^3.19.19",
3739 "node-pty": "^1.1.0",
3840 "open": "^10.1.0",
3941 "ws": "^8.18.0",
......@@ -41,6 +43,7 @@
4143 "devDependencies": {
4244 "@t3tools/contracts": "workspace:*",
4345 "@t3tools/web": "workspace:*",
46 "@types/better-sqlite3": "^7.6.13",
4447 "@types/node": "^22.10.2",
4548 "@types/ws": "^8.5.13",
4649 "tsdown": "^0.20.3",
......@@ -87,6 +90,7 @@
8790 "name": "@t3tools/contracts",
8891 "version": "0.0.0",
8992 "dependencies": {
93 "effect": "^3.19.19",
9094 "zod": "^3.24.1",
9195 },
9296 "devDependencies": {
......@@ -494,6 +498,8 @@
494498
495499 "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
496500
501 "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
502
497503 "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
498504
499505 "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
......@@ -582,18 +588,28 @@
582588
583589 "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
584590
591 "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
592
585593 "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
586594
595 "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
596
587597 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
588598
599 "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
600
589601 "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],
590602
603 "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
604
591605 "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
592606
593607 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
594608
595609 "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
596610
611 "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
612
597613 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
598614
599615 "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
......@@ -624,6 +640,8 @@
624640
625641 "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
626642
643 "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
644
627645 "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
628646
629647 "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
......@@ -656,6 +674,8 @@
656674
657675 "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
658676
677 "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
678
659679 "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="],
660680
661681 "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
......@@ -686,6 +706,8 @@
686706
687707 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
688708
709 "effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="],
710
689711 "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="],
690712
691713 "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
......@@ -726,22 +748,30 @@
726748
727749 "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
728750
751 "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
752
729753 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
730754
731755 "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
732756
733757 "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
734758
759 "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
760
735761 "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
736762
737763 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
738764
765 "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
766
739767 "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
740768
741769 "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
742770
743771 "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
744772
773 "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
774
745775 "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
746776
747777 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
......@@ -760,6 +790,8 @@
760790
761791 "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
762792
793 "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
794
763795 "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
764796
765797 "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
......@@ -808,10 +840,16 @@
808840
809841 "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
810842
843 "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
844
811845 "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="],
812846
813847 "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="],
814848
849 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
850
851 "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
852
815853 "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
816854
817855 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
......@@ -1006,6 +1044,8 @@
10061044
10071045 "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
10081046
1047 "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
1048
10091049 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
10101050
10111051 "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="],
......@@ -1014,6 +1054,10 @@
10141054
10151055 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
10161056
1057 "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
1058
1059 "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
1060
10171061 "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
10181062
10191063 "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
......@@ -1060,6 +1104,8 @@
10601104
10611105 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
10621106
1107 "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
1108
10631109 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
10641110
10651111 "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
......@@ -1070,10 +1116,14 @@
10701116
10711117 "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
10721118
1119 "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
1120
10731121 "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
10741122
10751123 "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
10761124
1125 "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
1126
10771127 "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
10781128
10791129 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
......@@ -1082,6 +1132,8 @@
10821132
10831133 "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
10841134
1135 "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
1136
10851137 "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
10861138
10871139 "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
......@@ -1132,6 +1184,8 @@
11321184
11331185 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
11341186
1187 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
1188
11351189 "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
11361190
11371191 "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
......@@ -1150,6 +1204,10 @@
11501204
11511205 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
11521206
1207 "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
1208
1209 "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
1210
11531211 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
11541212
11551213 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
......@@ -1168,10 +1226,14 @@
11681226
11691227 "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
11701228
1229 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
1230
11711231 "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
11721232
11731233 "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
11741234
1235 "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
1236
11751237 "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
11761238
11771239 "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
......@@ -1192,6 +1254,10 @@
11921254
11931255 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
11941256
1257 "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
1258
1259 "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
1260
11951261 "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
11961262
11971263 "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
......@@ -1226,6 +1292,8 @@
12261292
12271293 "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
12281294
1295 "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
1296
12291297 "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="],
12301298
12311299 "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="],
......@@ -1276,6 +1344,8 @@
12761344
12771345 "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
12781346
1347 "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
1348
12791349 "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
12801350
12811351 "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
......@@ -1366,6 +1436,8 @@
13661436
13671437 "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
13681438
1439 "@types/better-sqlite3/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="],
1440
13691441 "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
13701442
13711443 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
......@@ -1422,6 +1494,8 @@
14221494
14231495 "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
14241496
1497 "@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
1498
14251499 "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
14261500
14271501 "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="],
packages/contracts/package.json+1
......@@ -23,6 +23,7 @@
2323 "test": "vitest run"
2424 },
2525 "dependencies": {
26 "effect": "^3.19.19",
2627 "zod": "^3.24.1"
2728 },
2829 "devDependencies": {
packages/contracts/src/index.ts+1
......@@ -9,3 +9,4 @@ export * from "./keybindings";
99export * from "./server";
1010export * from "./project";
1111export * from "./git";
12export * from "./orchestration";
packages/contracts/src/ipc.ts+12-3
......@@ -16,7 +16,6 @@ import type {
1616 GitStatusResult,
1717} from "./git";
1818import type {
19 ProviderEvent,
2019 ProviderGetCheckpointDiffInput,
2120 ProviderGetCheckpointDiffResult,
2221 ProviderInterruptTurnInput,
......@@ -53,6 +52,11 @@ import type {
5352} from "./terminal";
5453import type { NewTodoInput, Todo } from "./todo";
5554import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
55import type {
56 OrchestrationCommand,
57 OrchestrationEvent,
58 OrchestrationReadModel,
59} from "./orchestration";
5660
5761export const EDITORS = [
5862 { id: "cursor", label: "Cursor", command: "cursor" },
......@@ -94,7 +98,6 @@ export interface NativeApi {
9498 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
9599 respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>;
96100 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
97 listSessions: () => Promise<ProviderSession[]>;
98101 listCheckpoints: (
99102 input: ProviderListCheckpointsInput,
100103 ) => Promise<ProviderListCheckpointsResult>;
......@@ -104,7 +107,6 @@ export interface NativeApi {
104107 revertToCheckpoint: (
105108 input: ProviderRevertToCheckpointInput,
106109 ) => Promise<ProviderRevertToCheckpointResult>;
107 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
108110 };
109111 projects: {
110112 list: () => Promise<ProjectListResult>;
......@@ -140,4 +142,11 @@ export interface NativeApi {
140142 getConfig: () => Promise<ServerConfig>;
141143 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
142144 };
145 orchestration: {
146 getSnapshot: () => Promise<OrchestrationReadModel>;
147 dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>;
148 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
149 onReadModel: (callback: (snapshot: OrchestrationReadModel) => void) => () => void;
150 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
151 };
143152}
packages/contracts/src/orchestration.ts created+223
......@@ -0,0 +1,223 @@
1import { Schema } from "effect";
2
3export const ORCHESTRATION_WS_METHODS = {
4 getSnapshot: "orchestration.getSnapshot",
5 dispatchCommand: "orchestration.dispatchCommand",
6 replayEvents: "orchestration.replayEvents",
7} as const;
8
9export const ORCHESTRATION_WS_CHANNELS = {
10 readModel: "orchestration.readModel",
11 domainEvent: "orchestration.domainEvent",
12} as const;
13
14const IsoDateTimeSchema = Schema.String;
15const IdSchema = Schema.String;
16
17export const OrchestrationMessageSchema = Schema.Struct({
18 id: IdSchema,
19 role: Schema.Literal("user", "assistant"),
20 text: Schema.String,
21 createdAt: IsoDateTimeSchema,
22 streaming: Schema.Boolean,
23});
24
25export type OrchestrationMessage = Schema.Schema.Type<typeof OrchestrationMessageSchema>;
26
27export const OrchestrationSessionSchema = Schema.Struct({
28 sessionId: IdSchema,
29 status: Schema.Literal("connecting", "ready", "running", "error", "closed"),
30 provider: Schema.Literal("codex", "claudeCode"),
31 threadId: IdSchema,
32 activeTurnId: Schema.NullOr(Schema.String),
33 createdAt: IsoDateTimeSchema,
34 updatedAt: IsoDateTimeSchema,
35 lastError: Schema.NullOr(Schema.String),
36});
37
38export type OrchestrationSession = Schema.Schema.Type<typeof OrchestrationSessionSchema>;
39
40export const OrchestrationTurnDiffFileSchema = Schema.Struct({
41 path: Schema.String,
42 kind: Schema.optional(Schema.String),
43 additions: Schema.optional(Schema.Number),
44 deletions: Schema.optional(Schema.Number),
45});
46
47export type OrchestrationTurnDiffFile = Schema.Schema.Type<typeof OrchestrationTurnDiffFileSchema>;
48
49export const OrchestrationTurnDiffSummarySchema = Schema.Struct({
50 turnId: IdSchema,
51 completedAt: IsoDateTimeSchema,
52 status: Schema.optional(Schema.String),
53 files: Schema.Array(OrchestrationTurnDiffFileSchema),
54 assistantMessageId: Schema.optional(Schema.String),
55 checkpointTurnCount: Schema.optional(Schema.Number),
56});
57
58export type OrchestrationTurnDiffSummary = Schema.Schema.Type<
59 typeof OrchestrationTurnDiffSummarySchema
60>;
61
62export const OrchestrationThreadSchema = Schema.Struct({
63 id: IdSchema,
64 projectId: IdSchema,
65 title: Schema.String,
66 model: Schema.String,
67 branch: Schema.NullOr(Schema.String),
68 worktreePath: Schema.NullOr(Schema.String),
69 createdAt: IsoDateTimeSchema,
70 updatedAt: IsoDateTimeSchema,
71 latestTurnId: Schema.NullOr(Schema.String),
72 latestTurnStartedAt: Schema.NullOr(Schema.String),
73 latestTurnCompletedAt: Schema.NullOr(Schema.String),
74 latestTurnDurationMs: Schema.NullOr(Schema.Number),
75 messages: Schema.Array(OrchestrationMessageSchema),
76 session: Schema.NullOr(OrchestrationSessionSchema),
77 turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema),
78 error: Schema.NullOr(Schema.String),
79});
80
81export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>;
82
83export const OrchestrationProjectSchema = Schema.Struct({
84 id: IdSchema,
85 name: Schema.String,
86 cwd: Schema.String,
87 model: Schema.String,
88 createdAt: IsoDateTimeSchema,
89 updatedAt: IsoDateTimeSchema,
90});
91
92export type OrchestrationProject = Schema.Schema.Type<typeof OrchestrationProjectSchema>;
93
94export const OrchestrationGitReadModelSchema = Schema.Struct({
95 projectId: IdSchema,
96 branch: Schema.NullOr(Schema.String),
97 hasWorkingTreeChanges: Schema.Boolean,
98 aheadCount: Schema.Number,
99 behindCount: Schema.Number,
100 updatedAt: IsoDateTimeSchema,
101});
102
103export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationGitReadModelSchema>;
104
105export const OrchestrationReadModelSchema = Schema.Struct({
106 sequence: Schema.Number,
107 projects: Schema.Array(OrchestrationProjectSchema),
108 threads: Schema.Array(OrchestrationThreadSchema),
109 gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }),
110 updatedAt: IsoDateTimeSchema,
111});
112
113export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>;
114
115export const CreateProjectCommandSchema = Schema.Struct({
116 type: Schema.Literal("project.create"),
117 commandId: IdSchema,
118 projectId: IdSchema,
119 name: Schema.String,
120 cwd: Schema.String,
121 model: Schema.String,
122 createdAt: IsoDateTimeSchema,
123});
124
125export const DeleteProjectCommandSchema = Schema.Struct({
126 type: Schema.Literal("project.delete"),
127 commandId: IdSchema,
128 projectId: IdSchema,
129 createdAt: IsoDateTimeSchema,
130});
131
132export const CreateThreadCommandSchema = Schema.Struct({
133 type: Schema.Literal("thread.create"),
134 commandId: IdSchema,
135 threadId: IdSchema,
136 projectId: IdSchema,
137 title: Schema.String,
138 model: Schema.String,
139 branch: Schema.NullOr(Schema.String),
140 worktreePath: Schema.NullOr(Schema.String),
141 createdAt: IsoDateTimeSchema,
142});
143
144export const DeleteThreadCommandSchema = Schema.Struct({
145 type: Schema.Literal("thread.delete"),
146 commandId: IdSchema,
147 threadId: IdSchema,
148 createdAt: IsoDateTimeSchema,
149});
150
151export const UpdateThreadMetaCommandSchema = Schema.Struct({
152 type: Schema.Literal("thread.meta.update"),
153 commandId: IdSchema,
154 threadId: IdSchema,
155 title: Schema.optional(Schema.String),
156 model: Schema.optional(Schema.String),
157 branch: Schema.optional(Schema.NullOr(Schema.String)),
158 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
159 createdAt: IsoDateTimeSchema,
160});
161
162export const SendMessageCommandSchema = Schema.Struct({
163 type: Schema.Literal("message.send"),
164 commandId: IdSchema,
165 threadId: IdSchema,
166 messageId: IdSchema,
167 role: Schema.Literal("user", "assistant"),
168 text: Schema.String,
169 streaming: Schema.optional(Schema.Boolean),
170 createdAt: IsoDateTimeSchema,
171});
172
173export const SetThreadSessionSchema = Schema.Struct({
174 type: Schema.Literal("thread.session"),
175 commandId: IdSchema,
176 threadId: IdSchema,
177 session: OrchestrationSessionSchema,
178 createdAt: IsoDateTimeSchema,
179});
180
181export const UpsertGitReadModelCommandSchema = Schema.Struct({
182 type: Schema.Literal("git.readModel.upsert"),
183 commandId: IdSchema,
184 projectId: IdSchema,
185 branch: Schema.NullOr(Schema.String),
186 hasWorkingTreeChanges: Schema.Boolean,
187 aheadCount: Schema.Number,
188 behindCount: Schema.Number,
189 createdAt: IsoDateTimeSchema,
190});
191
192export const OrchestrationCommandSchema = Schema.Union(
193 CreateProjectCommandSchema,
194 DeleteProjectCommandSchema,
195 CreateThreadCommandSchema,
196 DeleteThreadCommandSchema,
197 UpdateThreadMetaCommandSchema,
198 SendMessageCommandSchema,
199 SetThreadSessionSchema,
200 UpsertGitReadModelCommandSchema,
201);
202
203export type OrchestrationCommand = Schema.Schema.Type<typeof OrchestrationCommandSchema>;
204
205export const OrchestrationEventSchema = Schema.Struct({
206 sequence: Schema.Number,
207 eventId: IdSchema,
208 type: Schema.String,
209 aggregateType: Schema.String,
210 aggregateId: Schema.String,
211 occurredAt: IsoDateTimeSchema,
212 commandId: Schema.NullOr(Schema.String),
213 payload: Schema.Unknown,
214});
215
216export type OrchestrationEvent = Schema.Schema.Type<typeof OrchestrationEventSchema>;
217
218export const OrchestrationReadModelPushSchema = Schema.Struct({
219 sequence: Schema.Number,
220 snapshot: OrchestrationReadModelSchema,
221});
222
223export type OrchestrationReadModelPush = Schema.Schema.Type<typeof OrchestrationReadModelPushSchema>;
packages/contracts/src/ws.ts-2
......@@ -9,7 +9,6 @@ export const WS_METHODS = {
99 providersInterruptTurn: "providers.interruptTurn",
1010 providersRespondToRequest: "providers.respondToRequest",
1111 providersStopSession: "providers.stopSession",
12 providersListSessions: "providers.listSessions",
1312 providersListCheckpoints: "providers.listCheckpoints",
1413 providersGetCheckpointDiff: "providers.getCheckpointDiff",
1514 providersRevertToCheckpoint: "providers.revertToCheckpoint",
......@@ -51,7 +50,6 @@ export const WS_METHODS = {
5150// ── Push Event Channels ──────────────────────────────────────────────
5251
5352export const WS_CHANNELS = {
54 providerEvent: "providers.event",
5553 terminalEvent: "terminal.event",
5654 serverWelcome: "server.welcome",
5755} as const;