| author | |
| committer | |
| log | 483a1ae3718e39269075e99ea84e437d4cfad591 |
| tree | 65661a7e3c4a3b207504479fd72c9e766e87ea04 |
| parent | 781006f0054f3e40ec7f7eadd612b037ea52f5ff |
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: | ... | @@ -106,12 +106,12 @@ T3 Code has a global runtime mode switch in the chat toolbar: |
| 106 | The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: | 106 | The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: |
| 107 | 107 | ||
| 108 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` | 108 | - **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 |
| 110 | 110 | ||
| 111 | Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: | 111 | Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: |
| 112 | 112 | ||
| 113 | - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` | 113 | - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` |
| 114 | - `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` | 114 | - `providers.respondToRequest`, `providers.stopSession` |
| 115 | - `shell.openInEditor`, `server.getConfig` | 115 | - `shell.openInEditor`, `server.getConfig` |
| 116 | 116 | ||
| 117 | Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. | 117 | Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. |
apps/server/package.json+3| ... | @@ -17,6 +17,8 @@ | ... | @@ -17,6 +17,8 @@ |
| 17 | "test": "vitest run" | 17 | "test": "vitest run" |
| 18 | }, | 18 | }, |
| 19 | "dependencies": { | 19 | "dependencies": { |
| 20 | "better-sqlite3": "^12.6.2", | ||
| 21 | "effect": "^3.19.19", | ||
| 20 | "node-pty": "^1.1.0", | 22 | "node-pty": "^1.1.0", |
| 21 | "open": "^10.1.0", | 23 | "open": "^10.1.0", |
| 22 | "ws": "^8.18.0" | 24 | "ws": "^8.18.0" |
| ... | @@ -24,6 +26,7 @@ | ... | @@ -24,6 +26,7 @@ |
| 24 | "devDependencies": { | 26 | "devDependencies": { |
| 25 | "@t3tools/contracts": "workspace:*", | 27 | "@t3tools/contracts": "workspace:*", |
| 26 | "@t3tools/web": "workspace:*", | 28 | "@t3tools/web": "workspace:*", |
| 29 | "@types/better-sqlite3": "^7.6.13", | ||
| 27 | "@types/node": "^22.10.2", | 30 | "@types/node": "^22.10.2", |
| 28 | "@types/ws": "^8.5.13", | 31 | "@types/ws": "^8.5.13", |
| 29 | "tsdown": "^0.20.3", | 32 | "tsdown": "^0.20.3", |
apps/server/src/index.ts+1| ... | @@ -118,6 +118,7 @@ async function main() { | ... | @@ -118,6 +118,7 @@ async function main() { |
| 118 | port, | 118 | port, |
| 119 | host: mode === "desktop" ? "127.0.0.1" : undefined, | 119 | host: mode === "desktop" ? "127.0.0.1" : undefined, |
| 120 | cwd, | 120 | cwd, |
| 121 | stateDir, | ||
| 121 | staticDir, | 122 | staticDir, |
| 122 | devUrl, | 123 | devUrl, |
| 123 | projectRegistry, | 124 | projectRegistry, |
apps/server/src/orchestration/engine.test.ts created+119| ... | @@ -0,0 +1,119 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 6 | |||
| 7 | import { OrchestrationEngine } from "./engine"; | ||
| 8 | |||
| 9 | const tempDirs: string[] = []; | ||
| 10 | |||
| 11 | function makeTempDir(prefix: string): string { | ||
| 12 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); | ||
| 13 | tempDirs.push(dir); | ||
| 14 | return dir; | ||
| 15 | } | ||
| 16 | |||
| 17 | afterEach(() => { | ||
| 18 | for (const dir of tempDirs.splice(0, tempDirs.length)) { | ||
| 19 | fs.rmSync(dir, { recursive: true, force: true }); | ||
| 20 | } | ||
| 21 | }); | ||
| 22 | |||
| 23 | describe("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 @@ | ||
| 1 | import path from "node:path"; | ||
| 2 | |||
| 3 | import type { | ||
| 4 | OrchestrationCommand, | ||
| 5 | OrchestrationEvent, | ||
| 6 | OrchestrationReadModel, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | ||
| 9 | import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; | ||
| 10 | |||
| 11 | import { createLogger } from "../logger"; | ||
| 12 | import { SqliteEventStore } from "./eventStore"; | ||
| 13 | import { createEmptyReadModel, reduceEvent } from "./reducer"; | ||
| 14 | import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; | ||
| 15 | |||
| 16 | type CommandEnvelope = { | ||
| 17 | command: OrchestrationCommand; | ||
| 18 | resolve: (result: { sequence: number }) => void; | ||
| 19 | reject: (error: Error) => void; | ||
| 20 | }; | ||
| 21 | |||
| 22 | function 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 | |||
| 154 | export 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 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import path from "node:path"; | ||
| 3 | |||
| 4 | import type { OrchestrationEvent } from "@t3tools/contracts"; | ||
| 5 | import Database from "better-sqlite3"; | ||
| 6 | import { Effect } from "effect"; | ||
| 7 | |||
| 8 | interface 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 | |||
| 19 | export 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 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationEvent, | ||
| 3 | OrchestrationGitReadModel, | ||
| 4 | OrchestrationMessage, | ||
| 5 | OrchestrationProject, | ||
| 6 | OrchestrationReadModel, | ||
| 7 | OrchestrationSession, | ||
| 8 | OrchestrationThread, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; | ||
| 12 | |||
| 13 | function 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 | |||
| 21 | function asObject(value: unknown): Record<string, unknown> | null { | ||
| 22 | if (!value || typeof value !== "object") return null; | ||
| 23 | return value as Record<string, unknown>; | ||
| 24 | } | ||
| 25 | |||
| 26 | function asString(value: unknown): string | null { | ||
| 27 | return typeof value === "string" ? value : null; | ||
| 28 | } | ||
| 29 | |||
| 30 | function asNumber(value: unknown): number | null { | ||
| 31 | return typeof value === "number" && Number.isFinite(value) ? value : null; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { | ||
| 35 | return { | ||
| 36 | sequence: 0, | ||
| 37 | projects: [], | ||
| 38 | threads: [], | ||
| 39 | gitByProjectId: {}, | ||
| 40 | updatedAt: nowIso, | ||
| 41 | }; | ||
| 42 | } | ||
| 43 | |||
| 44 | export 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 @@ | ||
| 1 | export 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. | ||
| 9 | export 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", () => { | ... | @@ -621,7 +621,8 @@ describe("WebSocket Server", () => { |
| 621 | const ws = await connectWs(port); | 621 | const ws = await connectWs(port); |
| 622 | connections.push(ws); | 622 | connections.push(ws); |
| 623 | 623 | ||
| 624 | // Consume welcome | 624 | // Consume welcome + initial orchestration snapshot pushes |
| 625 | await waitForMessage(ws); | ||
| 625 | await waitForMessage(ws); | 626 | await waitForMessage(ws); |
| 626 | 627 | ||
| 627 | const response = await sendRequest(ws, "nonexistent.method"); | 628 | const response = await sendRequest(ws, "nonexistent.method"); |
| ... | @@ -629,23 +630,6 @@ describe("WebSocket Server", () => { | ... | @@ -629,23 +630,6 @@ describe("WebSocket Server", () => { |
| 629 | expect(response.error!.message).toContain("Unknown method"); | 630 | expect(response.error!.message).toContain("Unknown method"); |
| 630 | }); | 631 | }); |
| 631 | 632 | ||
| 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 | |||
| 649 | it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { | 633 | it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { |
| 650 | server = createTestServer({ cwd: "/test" }); | 634 | server = createTestServer({ cwd: "/test" }); |
| 651 | await server.start(); | 635 | await server.start(); |
| ... | @@ -800,9 +784,21 @@ describe("WebSocket Server", () => { | ... | @@ -800,9 +784,21 @@ describe("WebSocket Server", () => { |
| 800 | // Send garbage | 784 | // Send garbage |
| 801 | ws.send("not json at all"); | 785 | ws.send("not json at all"); |
| 802 | 786 | ||
| 803 | const response = (await waitForMessage(ws)) as WsResponse; | 787 | let response: WsResponse | null = null; |
| 804 | expect(response.error).toBeDefined(); | 788 | for (let attempt = 0; attempt < 5; attempt += 1) { |
| 805 | expect(response.error!.message).toContain("Invalid request format"); | 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"); | ||
| 806 | }); | 802 | }); |
| 807 | 803 | ||
| 808 | it("supports projects list/add/dedupe/remove", async () => { | 804 | 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"; | ... | @@ -7,8 +7,11 @@ import type { Duplex } from "node:stream"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | EDITORS, | 9 | EDITORS, |
| 10 | ORCHESTRATION_WS_CHANNELS, | ||
| 11 | ORCHESTRATION_WS_METHODS, | ||
| 10 | WS_CHANNELS, | 12 | WS_CHANNELS, |
| 11 | WS_METHODS, | 13 | WS_METHODS, |
| 14 | type OrchestrationReadModelPush, | ||
| 12 | type TerminalEvent, | 15 | type TerminalEvent, |
| 13 | type WsPush, | 16 | type WsPush, |
| 14 | type WsRequest, | 17 | type WsRequest, |
| ... | @@ -33,6 +36,7 @@ import { | ... | @@ -33,6 +36,7 @@ import { |
| 33 | import { TerminalManager } from "./terminalManager"; | 36 | import { TerminalManager } from "./terminalManager"; |
| 34 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; | 37 | import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; |
| 35 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 38 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 39 | import { OrchestrationEngine } from "./orchestration/engine"; | ||
| 36 | 40 | ||
| 37 | const MIME_TYPES: Record<string, string> = { | 41 | const MIME_TYPES: Record<string, string> = { |
| 38 | ".html": "text/html; charset=utf-8", | 42 | ".html": "text/html; charset=utf-8", |
| ... | @@ -53,6 +57,7 @@ export interface ServerOptions { | ... | @@ -53,6 +57,7 @@ export interface ServerOptions { |
| 53 | port: number; | 57 | port: number; |
| 54 | host?: string | undefined; | 58 | host?: string | undefined; |
| 55 | cwd: string; | 59 | cwd: string; |
| 60 | stateDir?: string | undefined; | ||
| 56 | staticDir?: string | undefined; | 61 | staticDir?: string | undefined; |
| 57 | devUrl?: string | undefined; | 62 | devUrl?: string | undefined; |
| 58 | logWebSocketEvents?: boolean | undefined; | 63 | logWebSocketEvents?: boolean | undefined; |
| ... | @@ -75,6 +80,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -75,6 +80,7 @@ export function createServer(options: ServerOptions) { |
| 75 | port, | 80 | port, |
| 76 | host, | 81 | host, |
| 77 | cwd, | 82 | cwd, |
| 83 | stateDir, | ||
| 78 | staticDir, | 84 | staticDir, |
| 79 | devUrl, | 85 | devUrl, |
| 80 | logWebSocketEvents: explicitLogWsEvents, | 86 | logWebSocketEvents: explicitLogWsEvents, |
| ... | @@ -88,6 +94,9 @@ export function createServer(options: ServerOptions) { | ... | @@ -88,6 +94,9 @@ export function createServer(options: ServerOptions) { |
| 88 | const projectRegistry = | 94 | const projectRegistry = |
| 89 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); | 95 | providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); |
| 90 | const gitManager = providedGitManager ?? new GitManager(); | 96 | const gitManager = providedGitManager ?? new GitManager(); |
| 97 | const orchestrationEngine = new OrchestrationEngine( | ||
| 98 | stateDir ?? path.join(os.homedir(), ".t3", "userdata"), | ||
| 99 | ); | ||
| 91 | const clients = new Set<WebSocket>(); | 100 | const clients = new Set<WebSocket>(); |
| 92 | const logger = createLogger("ws"); | 101 | const logger = createLogger("ws"); |
| 93 | const logWebSocketEvents = | 102 | const logWebSocketEvents = |
| ... | @@ -103,11 +112,30 @@ export function createServer(options: ServerOptions) { | ... | @@ -103,11 +112,30 @@ export function createServer(options: ServerOptions) { |
| 103 | }); | 112 | }); |
| 104 | } | 113 | } |
| 105 | 114 | ||
| 106 | // Forward provider events to all connected WebSocket clients | 115 | const unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { |
| 107 | providerManager.on("event", (event) => { | ||
| 108 | const push: WsPush = { | 116 | const push: WsPush = { |
| 109 | type: "push", | 117 | 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, | ||
| 111 | data: event, | 139 | data: event, |
| 112 | }; | 140 | }; |
| 113 | const message = JSON.stringify(push); | 141 | const message = JSON.stringify(push); |
| ... | @@ -121,6 +149,78 @@ export function createServer(options: ServerOptions) { | ... | @@ -121,6 +149,78 @@ export function createServer(options: ServerOptions) { |
| 121 | logOutgoingPush(push, recipients); | 149 | logOutgoingPush(push, recipients); |
| 122 | }); | 150 | }); |
| 123 | 151 | ||
| 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 | |||
| 124 | const onTerminalEvent = (event: TerminalEvent) => { | 224 | const onTerminalEvent = (event: TerminalEvent) => { |
| 125 | const push: WsPush = { | 225 | const push: WsPush = { |
| 126 | type: "push", | 226 | type: "push", |
| ... | @@ -247,6 +347,17 @@ export function createServer(options: ServerOptions) { | ... | @@ -247,6 +347,17 @@ export function createServer(options: ServerOptions) { |
| 247 | logOutgoingPush(welcome, 1); | 347 | logOutgoingPush(welcome, 1); |
| 248 | ws.send(JSON.stringify(welcome)); | 348 | ws.send(JSON.stringify(welcome)); |
| 249 | 349 | ||
| 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 | |||
| 250 | ws.on("message", (raw) => { | 361 | ws.on("message", (raw) => { |
| 251 | void handleMessage(ws, raw); | 362 | void handleMessage(ws, raw); |
| 252 | }); | 363 | }); |
| ... | @@ -307,9 +418,6 @@ export function createServer(options: ServerOptions) { | ... | @@ -307,9 +418,6 @@ export function createServer(options: ServerOptions) { |
| 307 | return undefined; | 418 | return undefined; |
| 308 | } | 419 | } |
| 309 | 420 | ||
| 310 | case WS_METHODS.providersListSessions: | ||
| 311 | return providerManager.listSessions(); | ||
| 312 | |||
| 313 | case WS_METHODS.providersListCheckpoints: | 421 | case WS_METHODS.providersListCheckpoints: |
| 314 | return providerManager.listCheckpoints(request.params as never); | 422 | return providerManager.listCheckpoints(request.params as never); |
| 315 | 423 | ||
| ... | @@ -323,11 +431,32 @@ export function createServer(options: ServerOptions) { | ... | @@ -323,11 +431,32 @@ export function createServer(options: ServerOptions) { |
| 323 | return projectRegistry.list(); | 431 | return projectRegistry.list(); |
| 324 | 432 | ||
| 325 | case WS_METHODS.projectsAdd: | 433 | 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 | } | ||
| 327 | 447 | ||
| 328 | case WS_METHODS.projectsRemove: | 448 | case WS_METHODS.projectsRemove: |
| 329 | projectRegistry.remove(request.params as never); | 449 | { |
| 330 | return undefined; | 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 | } | ||
| 331 | 460 | ||
| 332 | case WS_METHODS.projectsSearchEntries: | 461 | case WS_METHODS.projectsSearchEntries: |
| 333 | return searchWorkspaceEntries(request.params as never); | 462 | return searchWorkspaceEntries(request.params as never); |
| ... | @@ -379,7 +508,24 @@ export function createServer(options: ServerOptions) { | ... | @@ -379,7 +508,24 @@ export function createServer(options: ServerOptions) { |
| 379 | } | 508 | } |
| 380 | 509 | ||
| 381 | case WS_METHODS.gitStatus: | 510 | 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 | } | ||
| 383 | 529 | ||
| 384 | case WS_METHODS.gitPull: | 530 | case WS_METHODS.gitPull: |
| 385 | return pullGitBranch(request.params as never); | 531 | return pullGitBranch(request.params as never); |
| ... | @@ -438,6 +584,25 @@ export function createServer(options: ServerOptions) { | ... | @@ -438,6 +584,25 @@ export function createServer(options: ServerOptions) { |
| 438 | keybindings: keybindingsConfig, | 584 | keybindings: keybindingsConfig, |
| 439 | }; | 585 | }; |
| 440 | 586 | ||
| 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 | |||
| 441 | default: | 606 | default: |
| 442 | throw new Error(`Unknown method: ${request.method}`); | 607 | throw new Error(`Unknown method: ${request.method}`); |
| 443 | } | 608 | } |
| ... | @@ -450,9 +615,14 @@ export function createServer(options: ServerOptions) { | ... | @@ -450,9 +615,14 @@ export function createServer(options: ServerOptions) { |
| 450 | reject(error); | 615 | reject(error); |
| 451 | }; | 616 | }; |
| 452 | httpServer.once("error", onError); | 617 | httpServer.once("error", onError); |
| 453 | const onListening = () => { | 618 | const onListening = async () => { |
| 454 | httpServer.off("error", onError); | 619 | 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 | } | ||
| 456 | }; | 626 | }; |
| 457 | if (host) { | 627 | if (host) { |
| 458 | httpServer.listen(port, host, onListening); | 628 | httpServer.listen(port, host, onListening); |
| ... | @@ -463,6 +633,9 @@ export function createServer(options: ServerOptions) { | ... | @@ -463,6 +633,9 @@ export function createServer(options: ServerOptions) { |
| 463 | } | 633 | } |
| 464 | 634 | ||
| 465 | async function stop(): Promise<void> { | 635 | async function stop(): Promise<void> { |
| 636 | unsubscribeReadModel(); | ||
| 637 | unsubscribeDomainEvents(); | ||
| 638 | await orchestrationEngine.stop(); | ||
| 466 | terminalManager.off("event", onTerminalEvent); | 639 | terminalManager.off("event", onTerminalEvent); |
| 467 | providerManager.stopAll(); | 640 | providerManager.stopAll(); |
| 468 | providerManager.dispose(); | 641 | providerManager.dispose(); |
apps/web/src/components/BranchToolbar.tsx+21-1| ... | @@ -92,13 +92,23 @@ export default function BranchToolbar({ | ... | @@ -92,13 +92,23 @@ export default function BranchToolbar({ |
| 92 | }); | 92 | }); |
| 93 | if (!activeThreadId || !syncedBranch) return; | 93 | if (!activeThreadId || !syncedBranch) return; |
| 94 | 94 | ||
| 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 | } | ||
| 95 | dispatch({ | 105 | dispatch({ |
| 96 | type: "SET_THREAD_BRANCH", | 106 | type: "SET_THREAD_BRANCH", |
| 97 | threadId: activeThreadId, | 107 | threadId: activeThreadId, |
| 98 | branch: syncedBranch, | 108 | branch: syncedBranch, |
| 99 | worktreePath: null, | 109 | worktreePath: null, |
| 100 | }); | 110 | }); |
| 101 | }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]); | 111 | }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch, api]); |
| 102 | 112 | ||
| 103 | useEffect(() => { | 113 | useEffect(() => { |
| 104 | if (isBranchMenuOpen) return; | 114 | if (isBranchMenuOpen) return; |
| ... | @@ -120,6 +130,16 @@ export default function BranchToolbar({ | ... | @@ -120,6 +130,16 @@ export default function BranchToolbar({ |
| 120 | if (sessionId && worktreePath !== activeWorktreePath) { | 130 | if (sessionId && worktreePath !== activeWorktreePath) { |
| 121 | void api?.providers.stopSession({ sessionId }).catch(() => undefined); | 131 | void api?.providers.stopSession({ sessionId }).catch(() => undefined); |
| 122 | } | 132 | } |
| 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 | } | ||
| 123 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); | 143 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); |
| 124 | }; | 144 | }; |
| 125 | 145 |
apps/web/src/components/ChatView.tsx+65-113| ... | @@ -57,7 +57,6 @@ import { | ... | @@ -57,7 +57,6 @@ import { |
| 57 | derivePhase, | 57 | derivePhase, |
| 58 | deriveTurnDiffFilesFromUnifiedDiff, | 58 | deriveTurnDiffFilesFromUnifiedDiff, |
| 59 | deriveTimelineEntries, | 59 | deriveTimelineEntries, |
| 60 | type TurnDiffSummary, | ||
| 61 | type PendingApproval, | 60 | type PendingApproval, |
| 62 | deriveWorkLogEntries, | 61 | deriveWorkLogEntries, |
| 63 | formatDuration, | 62 | formatDuration, |
| ... | @@ -72,6 +71,7 @@ import { | ... | @@ -72,6 +71,7 @@ import { |
| 72 | MAX_THREAD_TERMINAL_COUNT, | 71 | MAX_THREAD_TERMINAL_COUNT, |
| 73 | type ChatImageAttachment, | 72 | type ChatImageAttachment, |
| 74 | type TurnDiffFileChange, | 73 | type TurnDiffFileChange, |
| 74 | type TurnDiffSummary, | ||
| 75 | } from "../types"; | 75 | } from "../types"; |
| 76 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 76 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 77 | import { useTheme } from "../hooks/useTheme"; | 77 | import { useTheme } from "../hooks/useTheme"; |
| ... | @@ -433,28 +433,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -433,28 +433,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 433 | const phase = derivePhase(activeThread?.session ?? null); | 433 | const phase = derivePhase(activeThread?.session ?? null); |
| 434 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; | 434 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; |
| 435 | const nowIso = new Date(nowTick).toISOString(); | 435 | const nowIso = new Date(nowTick).toISOString(); |
| 436 | const workLogEntries = useMemo( | 436 | const workLogEntries = useMemo(() => deriveWorkLogEntries([], undefined), []); |
| 437 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | ||
| 438 | [activeThread?.events], | ||
| 439 | ); | ||
| 440 | const latestTurnWorkEntries = useMemo( | 437 | const latestTurnWorkEntries = useMemo( |
| 441 | () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), | 438 | () => deriveWorkLogEntries([], activeThread?.latestTurnId), |
| 442 | [activeThread?.events, activeThread?.latestTurnId], | 439 | [activeThread?.latestTurnId], |
| 443 | ); | ||
| 444 | const pendingApprovals = useMemo( | ||
| 445 | () => derivePendingApprovals(activeThread?.events ?? []), | ||
| 446 | [activeThread?.events], | ||
| 447 | ); | 440 | ); |
| 448 | const assistantCompletionByItemId = useMemo(() => { | 441 | const pendingApprovals = useMemo(() => derivePendingApprovals([]), []); |
| 449 | const map = new Map<string, string>(); | 442 | const assistantCompletionByItemId = useMemo(() => 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]); | ||
| 458 | const timelineEntries = useMemo( | 443 | const timelineEntries = useMemo( |
| 459 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), | 444 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), |
| 460 | [activeThread?.messages, workLogEntries], | 445 | [activeThread?.messages, workLogEntries], |
| ... | @@ -565,36 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -565,36 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 565 | return byUserMessageId; | 550 | return byUserMessageId; |
| 566 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | 551 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); |
| 567 | 552 | ||
| 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 | |||
| 598 | const completionSummary = useMemo(() => { | 553 | const completionSummary = useMemo(() => { |
| 599 | if (!activeThread?.latestTurnStartedAt) return null; | 554 | if (!activeThread?.latestTurnStartedAt) return null; |
| 600 | if (!activeThread.latestTurnCompletedAt) return null; | 555 | if (!activeThread.latestTurnCompletedAt) return null; |
| ... | @@ -1473,28 +1428,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1473,28 +1428,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1473 | checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); | 1428 | checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); |
| 1474 | 1429 | ||
| 1475 | try { | 1430 | try { |
| 1476 | const result = await api.providers.listCheckpoints({ | 1431 | await api.providers.listCheckpoints({ |
| 1477 | sessionId: input.sessionId, | 1432 | sessionId: input.sessionId, |
| 1478 | }); | 1433 | }); |
| 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 | }); | ||
| 1493 | } catch { | 1434 | } catch { |
| 1494 | checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); | 1435 | checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); |
| 1495 | } | 1436 | } |
| 1496 | }, | 1437 | }, |
| 1497 | [api, dispatch, turnDiffSummaries], | 1438 | [api, turnDiffSummaries], |
| 1498 | ); | 1439 | ); |
| 1499 | 1440 | ||
| 1500 | const ensureSession = useCallback( | 1441 | const ensureSession = useCallback( |
| ... | @@ -1533,10 +1474,21 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1533,10 +1474,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1533 | approvalPolicy: runtimeApprovalPolicy, | 1474 | approvalPolicy: runtimeApprovalPolicy, |
| 1534 | sandboxMode: runtimeSandboxMode, | 1475 | sandboxMode: runtimeSandboxMode, |
| 1535 | }); | 1476 | }); |
| 1536 | dispatch({ | 1477 | await api.orchestration.dispatchCommand({ |
| 1537 | type: "UPDATE_SESSION", | 1478 | type: "thread.session", |
| 1479 | commandId: crypto.randomUUID(), | ||
| 1538 | threadId: activeThread.id, | 1480 | 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(), | ||
| 1540 | }); | 1492 | }); |
| 1541 | const resolvedThreadId = session.threadId ?? null; | 1493 | const resolvedThreadId = session.threadId ?? null; |
| 1542 | const continuityState: SessionContinuityState = | 1494 | const continuityState: SessionContinuityState = |
| ... | @@ -1572,7 +1524,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1572,7 +1524,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1572 | api, | 1524 | api, |
| 1573 | codexBinaryPath, | 1525 | codexBinaryPath, |
| 1574 | codexHomePath, | 1526 | codexHomePath, |
| 1575 | dispatch, | ||
| 1576 | runtimeApprovalPolicy, | 1527 | runtimeApprovalPolicy, |
| 1577 | runtimeSandboxMode, | 1528 | runtimeSandboxMode, |
| 1578 | selectedModel, | 1529 | selectedModel, |
| ... | @@ -1642,27 +1593,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1642,27 +1593,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1642 | if (!sessionInfo) { | 1593 | if (!sessionInfo) { |
| 1643 | return; | 1594 | return; |
| 1644 | } | 1595 | } |
| 1645 | const result = await api.providers.revertToCheckpoint({ | 1596 | await api.providers.revertToCheckpoint({ |
| 1646 | sessionId: sessionInfo.sessionId, | 1597 | sessionId: sessionInfo.sessionId, |
| 1647 | turnCount, | 1598 | turnCount, |
| 1648 | }); | 1599 | }); |
| 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 | }); | ||
| 1666 | } catch (err) { | 1600 | } catch (err) { |
| 1667 | setThreadError( | 1601 | setThreadError( |
| 1668 | activeThread.id, | 1602 | activeThread.id, |
| ... | @@ -1709,12 +1643,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1709,12 +1643,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1709 | newBranch, | 1643 | newBranch, |
| 1710 | }); | 1644 | }); |
| 1711 | sessionCwd = result.worktree.path; | 1645 | sessionCwd = result.worktree.path; |
| 1712 | dispatch({ | 1646 | if (api) { |
| 1713 | type: "SET_THREAD_BRANCH", | 1647 | await api.orchestration.dispatchCommand({ |
| 1714 | threadId: activeThread.id, | 1648 | type: "thread.meta.update", |
| 1715 | branch: result.worktree.branch, | 1649 | commandId: crypto.randomUUID(), |
| 1716 | worktreePath: result.worktree.path, | 1650 | threadId: activeThread.id, |
| 1717 | }); | 1651 | branch: result.worktree.branch, |
| 1652 | worktreePath: result.worktree.path, | ||
| 1653 | createdAt: new Date().toISOString(), | ||
| 1654 | }); | ||
| 1655 | } | ||
| 1718 | const setupScript = setupProjectScript(activeProject.scripts); | 1656 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1719 | if (setupScript) { | 1657 | if (setupScript) { |
| 1720 | void runProjectScript(setupScript, { | 1658 | void runProjectScript(setupScript, { |
| ... | @@ -1741,11 +1679,15 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1741,11 +1679,15 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1741 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` | 1679 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 1742 | : "New thread"); | 1680 | : "New thread"); |
| 1743 | const title = truncateTitle(titleSeed); | 1681 | const title = truncateTitle(titleSeed); |
| 1744 | dispatch({ | 1682 | if (api) { |
| 1745 | type: "SET_THREAD_TITLE", | 1683 | await api.orchestration.dispatchCommand({ |
| 1746 | threadId: activeThread.id, | 1684 | type: "thread.meta.update", |
| 1747 | title, | 1685 | commandId: crypto.randomUUID(), |
| 1748 | }); | 1686 | threadId: activeThread.id, |
| 1687 | title, | ||
| 1688 | createdAt: new Date().toISOString(), | ||
| 1689 | }); | ||
| 1690 | } | ||
| 1749 | } | 1691 | } |
| 1750 | 1692 | ||
| 1751 | setThreadError(activeThread.id, null); | 1693 | setThreadError(activeThread.id, null); |
| ... | @@ -1757,13 +1699,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1757,13 +1699,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1757 | sizeBytes: image.sizeBytes, | 1699 | sizeBytes: image.sizeBytes, |
| 1758 | previewUrl: image.previewUrl, | 1700 | previewUrl: image.previewUrl, |
| 1759 | })); | 1701 | })); |
| 1760 | dispatch({ | 1702 | const userMessageId = crypto.randomUUID(); |
| 1761 | type: "PUSH_USER_MESSAGE", | 1703 | if (api) { |
| 1762 | threadId: activeThread.id, | 1704 | await api.orchestration.dispatchCommand({ |
| 1763 | id: crypto.randomUUID(), | 1705 | type: "message.send", |
| 1764 | text: trimmed, | 1706 | commandId: crypto.randomUUID(), |
| 1765 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), | 1707 | threadId: activeThread.id, |
| 1766 | }); | 1708 | messageId: userMessageId, |
| 1709 | role: "user", | ||
| 1710 | text: trimmed, | ||
| 1711 | streaming: false, | ||
| 1712 | createdAt: new Date().toISOString(), | ||
| 1713 | }); | ||
| 1714 | } | ||
| 1767 | const previousMessages = activeThread.messages; | 1715 | const previousMessages = activeThread.messages; |
| 1768 | promptRef.current = ""; | 1716 | promptRef.current = ""; |
| 1769 | setPrompt(""); | 1717 | setPrompt(""); |
| ... | @@ -1852,14 +1800,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1852,14 +1800,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1852 | const onModelSelect = useCallback( | 1800 | const onModelSelect = useCallback( |
| 1853 | (model: ModelSlug) => { | 1801 | (model: ModelSlug) => { |
| 1854 | if (!activeThread) return; | 1802 | if (!activeThread) return; |
| 1855 | dispatch({ | 1803 | if (api) { |
| 1856 | type: "SET_THREAD_MODEL", | 1804 | void api.orchestration.dispatchCommand({ |
| 1857 | threadId: activeThread.id, | 1805 | type: "thread.meta.update", |
| 1858 | model: resolveModelSlug(model), | 1806 | commandId: crypto.randomUUID(), |
| 1859 | }); | 1807 | threadId: activeThread.id, |
| 1808 | model: resolveModelSlug(model), | ||
| 1809 | createdAt: new Date().toISOString(), | ||
| 1810 | }); | ||
| 1811 | } | ||
| 1860 | scheduleComposerFocus(); | 1812 | scheduleComposerFocus(); |
| 1861 | }, | 1813 | }, |
| 1862 | [activeThread, dispatch, scheduleComposerFocus], | 1814 | [activeThread, api, scheduleComposerFocus], |
| 1863 | ); | 1815 | ); |
| 1864 | const onEffortSelect = useCallback( | 1816 | const onEffortSelect = useCallback( |
| 1865 | (effort: ReasoningEffort) => { | 1817 | (effort: ReasoningEffort) => { |
apps/web/src/components/Sidebar.tsx+51-60| ... | @@ -7,16 +7,14 @@ import { useAppSettings } from "../appSettings"; | ... | @@ -7,16 +7,14 @@ import { useAppSettings } from "../appSettings"; |
| 7 | import { isElectron } from "../env"; | 7 | import { isElectron } from "../env"; |
| 8 | import { APP_STAGE_LABEL } from "../branding"; | 8 | import { APP_STAGE_LABEL } from "../branding"; |
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 10 | import { derivePendingApprovals } from "../session-logic"; | ||
| 11 | import { useStore } from "../store"; | 10 | import { useStore } from "../store"; |
| 12 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | 11 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 13 | import { type Project, type Thread } from "../types"; | 12 | import { type Thread } from "../types"; |
| 14 | import { useNativeApi } from "../hooks/useNativeApi"; | 13 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 15 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 14 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; |
| 16 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 15 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 17 | import { toastManager } from "./ui/toast"; | 16 | import { toastManager } from "./ui/toast"; |
| 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 17 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 19 | import { createThread } from "../threadFactory"; | ||
| 20 | 18 | ||
| 21 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 19 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 22 | 20 | ||
| ... | @@ -131,13 +129,7 @@ export default function Sidebar() { | ... | @@ -131,13 +129,7 @@ export default function Sidebar() { |
| 131 | const [newCwd, setNewCwd] = useState(""); | 129 | const [newCwd, setNewCwd] = useState(""); |
| 132 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 130 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 133 | const [isAddingProject, setIsAddingProject] = useState(false); | 131 | const [isAddingProject, setIsAddingProject] = useState(false); |
| 134 | const pendingApprovalByThreadId = useMemo(() => { | 132 | const pendingApprovalByThreadId = useMemo(() => new Map<string, boolean>(), []); |
| 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]); | ||
| 141 | 133 | ||
| 142 | const handleNewThread = useCallback( | 134 | const handleNewThread = useCallback( |
| 143 | ( | 135 | ( |
| ... | @@ -147,21 +139,27 @@ export default function Sidebar() { | ... | @@ -147,21 +139,27 @@ export default function Sidebar() { |
| 147 | worktreePath?: string | null; | 139 | worktreePath?: string | null; |
| 148 | }, | 140 | }, |
| 149 | ) => { | 141 | ) => { |
| 150 | const thread = createThread(projectId, { | 142 | if (!api) return; |
| 151 | model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, | 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, | ||
| 152 | branch: options?.branch ?? null, | 153 | branch: options?.branch ?? null, |
| 153 | worktreePath: options?.worktreePath ?? null, | 154 | worktreePath: options?.worktreePath ?? null, |
| 154 | }); | 155 | createdAt, |
| 155 | dispatch({ | ||
| 156 | type: "ADD_THREAD", | ||
| 157 | thread, | ||
| 158 | }); | 156 | }); |
| 159 | void navigate({ | 157 | void navigate({ |
| 160 | to: "/$threadId", | 158 | to: "/$threadId", |
| 161 | params: { threadId: thread.id }, | 159 | params: { threadId }, |
| 162 | }); | 160 | }); |
| 163 | }, | 161 | }, |
| 164 | [dispatch, navigate, state.projects], | 162 | [api, dispatch, navigate, state.projects], |
| 165 | ); | 163 | ); |
| 166 | 164 | ||
| 167 | const focusMostRecentThreadForProject = useCallback( | 165 | const focusMostRecentThreadForProject = useCallback( |
| ... | @@ -190,47 +188,30 @@ export default function Sidebar() { | ... | @@ -190,47 +188,30 @@ export default function Sidebar() { |
| 190 | 188 | ||
| 191 | setIsAddingProject(true); | 189 | setIsAddingProject(true); |
| 192 | try { | 190 | try { |
| 193 | if (isElectron && api) { | 191 | if (!api) return; |
| 194 | const result = await api.projects.add({ cwd }); | 192 | const existing = state.projects.find((project) => project.cwd === cwd); |
| 195 | const project: Project = { | 193 | if (existing) { |
| 196 | id: result.project.id, | 194 | focusMostRecentThreadForProject(existing.id); |
| 197 | name: result.project.name, | 195 | return; |
| 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); | ||
| 233 | } | 196 | } |
| 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); | ||
| 234 | } finally { | 215 | } finally { |
| 235 | setIsAddingProject(false); | 216 | setIsAddingProject(false); |
| 236 | setNewCwd(""); | 217 | setNewCwd(""); |
| ... | @@ -321,7 +302,12 @@ export default function Sidebar() { | ... | @@ -321,7 +302,12 @@ export default function Sidebar() { |
| 321 | 302 | ||
| 322 | const shouldNavigateToFallback = routeThreadId === threadId; | 303 | const shouldNavigateToFallback = routeThreadId === threadId; |
| 323 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; | 304 | 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 | }); | ||
| 325 | if (shouldNavigateToFallback) { | 311 | if (shouldNavigateToFallback) { |
| 326 | if (fallbackThreadId) { | 312 | if (fallbackThreadId) { |
| 327 | void navigate({ | 313 | void navigate({ |
| ... | @@ -411,7 +397,12 @@ export default function Sidebar() { | ... | @@ -411,7 +397,12 @@ export default function Sidebar() { |
| 411 | } | 397 | } |
| 412 | } | 398 | } |
| 413 | 399 | ||
| 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 | }); | ||
| 415 | }, | 406 | }, |
| 416 | [api, dispatch, state.projects, state.threads], | 407 | [api, dispatch, state.projects, state.threads], |
| 417 | ); | 408 | ); |
apps/web/src/hooks/useNativeApi.ts+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { readNativeApi } from "../session-logic"; | 2 | import { readNativeApi } from "../nativeApi"; |
| 3 | 3 | ||
| 4 | export function useNativeApi() { | 4 | export function useNativeApi() { |
| 5 | return useMemo(() => readNativeApi(), []); | 5 | return useMemo(() => readNativeApi(), []); |
apps/web/src/hooks/useTurnDiffSummaries.ts+2-18| ... | @@ -1,29 +1,13 @@ | ... | @@ -1,29 +1,13 @@ |
| 1 | import type { ProviderEvent } from "@t3tools/contracts"; | ||
| 2 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 3 | import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic"; | 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; |
| 4 | import type { Thread, TurnDiffSummary } from "../types"; | 3 | import type { Thread, TurnDiffSummary } from "../types"; |
| 5 | 4 | ||
| 6 | const derivedTurnDiffSummaryCache = new WeakMap<readonly ProviderEvent[], TurnDiffSummary[]>(); | ||
| 7 | |||
| 8 | function 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 | |||
| 18 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { | 5 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { |
| 19 | const turnDiffSummaries = useMemo(() => { | 6 | const turnDiffSummaries = useMemo(() => { |
| 20 | if (!activeThread) { | 7 | if (!activeThread) { |
| 21 | return []; | 8 | return []; |
| 22 | } | 9 | } |
| 23 | if (activeThread.turnDiffSummaries.length > 0) { | 10 | return activeThread.turnDiffSummaries; |
| 24 | return activeThread.turnDiffSummaries; | ||
| 25 | } | ||
| 26 | return deriveTurnDiffSummariesCached(activeThread.events); | ||
| 27 | }, [activeThread]); | 11 | }, [activeThread]); |
| 28 | 12 | ||
| 29 | const inferredCheckpointTurnCountByTurnId = useMemo( | 13 | const inferredCheckpointTurnCountByTurnId = useMemo( |
apps/web/src/nativeApi.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import type { NativeApi } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | ||
| 4 | |||
| 5 | let cachedApi: NativeApi | undefined; | ||
| 6 | |||
| 7 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { DEFAULT_MODEL } from "./model-logic"; | ||
| 4 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | ||
| 5 | import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; | ||
| 6 | import type { Thread } from "./types"; | ||
| 7 | |||
| 8 | describe("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 | |||
| 367 | describe("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 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | import { projectScriptSchema } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | ||
| 5 | import { | ||
| 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 | |||
| 15 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 16 | |||
| 17 | const 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 | |||
| 26 | const 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 | |||
| 45 | const persistedTerminalGroupSchema = z.object({ | ||
| 46 | id: z.string().trim().min(1), | ||
| 47 | terminalIds: z.array(z.string().trim().min(1)), | ||
| 48 | }); | ||
| 49 | |||
| 50 | const 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 | |||
| 57 | const 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 | |||
| 66 | const 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 | |||
| 89 | const persistedStateBodySchema = z.object({ | ||
| 90 | projects: z.array(persistedProjectSchema), | ||
| 91 | threads: z.array(persistedThreadSchema), | ||
| 92 | activeThreadId: z.string().min(1).nullable().optional(), | ||
| 93 | }); | ||
| 94 | |||
| 95 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | ||
| 96 | |||
| 97 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ | ||
| 98 | version: z.literal(1).optional(), | ||
| 99 | }); | ||
| 100 | |||
| 101 | export const persistedStateV2Schema = persistedStateBodySchema.extend({ | ||
| 102 | version: z.literal(2).optional(), | ||
| 103 | }); | ||
| 104 | |||
| 105 | export const persistedStateV3Schema = persistedStateBodySchema.extend({ | ||
| 106 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 107 | version: z.literal(3).optional(), | ||
| 108 | }); | ||
| 109 | |||
| 110 | export const persistedStateV4Schema = persistedStateBodySchema.extend({ | ||
| 111 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 112 | version: z.literal(4).optional(), | ||
| 113 | }); | ||
| 114 | |||
| 115 | export const persistedStateV6Schema = persistedStateBodySchema.extend({ | ||
| 116 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 117 | version: z.literal(6).optional(), | ||
| 118 | }); | ||
| 119 | |||
| 120 | export const persistedStateV7Schema = persistedStateBodySchema.extend({ | ||
| 121 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 122 | version: z.literal(7).optional(), | ||
| 123 | }); | ||
| 124 | |||
| 125 | export const persistedStateV5Schema = persistedStateBodySchema.extend({ | ||
| 126 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 127 | version: z.literal(5).optional(), | ||
| 128 | }); | ||
| 129 | |||
| 130 | const persistedStateSchema = z.union([ | ||
| 131 | persistedStateV7Schema, | ||
| 132 | persistedStateV6Schema, | ||
| 133 | persistedStateV5Schema, | ||
| 134 | persistedStateV4Schema, | ||
| 135 | persistedStateV3Schema, | ||
| 136 | persistedStateV2Schema, | ||
| 137 | persistedStateV1Schema, | ||
| 138 | ]); | ||
| 139 | |||
| 140 | export interface PersistedStoreSnapshot { | ||
| 141 | projects: Project[]; | ||
| 142 | threads: Thread[]; | ||
| 143 | runtimeMode: RuntimeMode; | ||
| 144 | } | ||
| 145 | |||
| 146 | function 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 | |||
| 154 | function 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 | |||
| 165 | function 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 | |||
| 310 | export 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 | |||
| 341 | export 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 { | ... | @@ -2,17 +2,15 @@ import { |
| 2 | Outlet, | 2 | Outlet, |
| 3 | createRootRouteWithContext, | 3 | createRootRouteWithContext, |
| 4 | type ErrorComponentProps, | 4 | type ErrorComponentProps, |
| 5 | useParams, | ||
| 6 | } from "@tanstack/react-router"; | 5 | } from "@tanstack/react-router"; |
| 7 | import { useEffect, useRef } from "react"; | 6 | import { useEffect, useRef } from "react"; |
| 8 | import { QueryClient, useQueryClient } from "@tanstack/react-query"; | 7 | import { QueryClient } from "@tanstack/react-query"; |
| 9 | 8 | ||
| 10 | import { APP_DISPLAY_NAME } from "../branding"; | 9 | import { APP_DISPLAY_NAME } from "../branding"; |
| 11 | import { Button } from "../components/ui/button"; | 10 | import { Button } from "../components/ui/button"; |
| 12 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; | 11 | import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; |
| 13 | import { isElectron } from "../env"; | 12 | import { isElectron } from "../env"; |
| 14 | import { useNativeApi } from "../hooks/useNativeApi"; | 13 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 15 | import { invalidateGitQueries } from "../lib/gitReactQuery"; | ||
| 16 | import { DEFAULT_MODEL } from "../model-logic"; | 14 | import { DEFAULT_MODEL } from "../model-logic"; |
| 17 | import { useStore } from "../store"; | 15 | import { useStore } from "../store"; |
| 18 | import { onServerWelcome } from "../wsNativeApi"; | 16 | import { onServerWelcome } from "../wsNativeApi"; |
| ... | @@ -125,67 +123,33 @@ function errorDetails(error: unknown): string { | ... | @@ -125,67 +123,33 @@ function errorDetails(error: unknown): string { |
| 125 | function EventRouter() { | 123 | function EventRouter() { |
| 126 | const api = useNativeApi(); | 124 | const api = useNativeApi(); |
| 127 | const { dispatch } = useStore(); | 125 | 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 | }); | ||
| 134 | 126 | ||
| 135 | useEffect(() => { | 127 | useEffect(() => { |
| 136 | if (!api) return; | 128 | if (!api) return; |
| 137 | return api.providers.onEvent((event) => { | 129 | void api.orchestration |
| 138 | if (event.method === "turn/completed") { | 130 | .getSnapshot() |
| 139 | void invalidateGitQueries(queryClient); | 131 | .then((snapshot) => { |
| 140 | } | 132 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); |
| 141 | if (event.method === "checkpoint/captured") { | 133 | }) |
| 142 | const payload = event.payload as { turnCount?: number } | undefined; | 134 | .catch(() => undefined); |
| 143 | const turnCount = payload?.turnCount; | 135 | const unsubReadModel = api.orchestration.onReadModel((snapshot) => { |
| 144 | void queryClient.invalidateQueries({ | 136 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); |
| 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 | }); | ||
| 178 | }); | 137 | }); |
| 138 | return () => { | ||
| 139 | unsubReadModel(); | ||
| 140 | }; | ||
| 179 | }, [api, dispatch]); | 141 | }, [api, dispatch]); |
| 180 | 142 | ||
| 181 | return null; | 143 | return null; |
| 182 | } | 144 | } |
| 183 | 145 | ||
| 184 | function AutoProjectBootstrap() { | 146 | function AutoProjectBootstrap() { |
| 147 | const api = useNativeApi(); | ||
| 185 | const { state, dispatch } = useStore(); | 148 | const { state, dispatch } = useStore(); |
| 186 | const bootstrappedRef = useRef(false); | 149 | const bootstrappedRef = useRef(false); |
| 187 | 150 | ||
| 188 | useEffect(() => { | 151 | useEffect(() => { |
| 152 | if (!api) return; | ||
| 189 | // Browser mode bootstraps from server welcome. | 153 | // Browser mode bootstraps from server welcome. |
| 190 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | 154 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. |
| 191 | if (isElectron) return; | 155 | if (isElectron) return; |
| ... | @@ -202,75 +166,40 @@ function AutoProjectBootstrap() { | ... | @@ -202,75 +166,40 @@ function AutoProjectBootstrap() { |
| 202 | } | 166 | } |
| 203 | 167 | ||
| 204 | bootstrappedRef.current = true; | 168 | bootstrappedRef.current = true; |
| 205 | |||
| 206 | // Create project + thread from server cwd | ||
| 207 | const projectId = crypto.randomUUID(); | 169 | const projectId = crypto.randomUUID(); |
| 208 | dispatch({ | 170 | const threadId = crypto.randomUUID(); |
| 209 | type: "ADD_PROJECT", | 171 | const now = new Date().toISOString(); |
| 210 | project: { | 172 | void api.orchestration |
| 211 | id: projectId, | 173 | .dispatchCommand({ |
| 174 | type: "project.create", | ||
| 175 | commandId: crypto.randomUUID(), | ||
| 176 | projectId, | ||
| 212 | name: payload.projectName, | 177 | name: payload.projectName, |
| 213 | cwd: payload.cwd, | 178 | cwd: payload.cwd, |
| 214 | model: DEFAULT_MODEL, | 179 | model: DEFAULT_MODEL, |
| 215 | expanded: true, | 180 | createdAt: now, |
| 216 | scripts: [], | 181 | }) |
| 217 | }, | 182 | .then(() => |
| 218 | }); | 183 | api.orchestration.dispatchCommand({ |
| 219 | dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); | 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); | ||
| 220 | }); | 196 | }); |
| 221 | }, [state.projects, dispatch]); | 197 | }, [api, state.projects, dispatch]); |
| 222 | 198 | ||
| 223 | return null; | 199 | return null; |
| 224 | } | 200 | } |
| 225 | 201 | ||
| 226 | function DesktopProjectBootstrap() { | 202 | function DesktopProjectBootstrap() { |
| 227 | const api = useNativeApi(); | 203 | // Desktop now hydrates from orchestration read-model snapshots. |
| 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 | |||
| 275 | return null; | 204 | return null; |
| 276 | } | 205 | } |
apps/web/src/session-logic.test.ts deleted-935| ... | @@ -1,935 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 4 | import { | ||
| 5 | type WorkLogEntry, | ||
| 6 | applyEventToMessages, | ||
| 7 | derivePendingApprovals, | ||
| 8 | deriveTurnDiffFilesFromUnifiedDiff, | ||
| 9 | deriveTurnDiffSummaries, | ||
| 10 | deriveTimelineEntries, | ||
| 11 | deriveWorkLogEntries, | ||
| 12 | evolveSession, | ||
| 13 | } from "./session-logic"; | ||
| 14 | import type { ChatMessage } from "./types"; | ||
| 15 | |||
| 16 | function 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 | |||
| 28 | function 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 | |||
| 39 | describe("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 | |||
| 107 | describe("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 | |||
| 398 | describe("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 | |||
| 582 | describe("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 | |||
| 631 | describe("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 | |||
| 698 | describe("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 | |||
| 807 | describe("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 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; | 1 | import type { ProviderKind, ProviderSession } from "@t3tools/contracts"; |
| 2 | |||
| 2 | import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; | 3 | import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; |
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | ||
| 4 | 4 | ||
| 5 | export const PROVIDER_OPTIONS: Array<{ | 5 | export const PROVIDER_OPTIONS: Array<{ |
| 6 | value: ProviderKind; | 6 | value: ProviderKind; |
| ... | @@ -11,73 +11,6 @@ export const PROVIDER_OPTIONS: Array<{ | ... | @@ -11,73 +11,6 @@ export const PROVIDER_OPTIONS: Array<{ |
| 11 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, | 11 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, |
| 12 | ]; | 12 | ]; |
| 13 | 13 | ||
| 14 | let cachedApi: NativeApi | undefined; | ||
| 15 | |||
| 16 | export 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 | |||
| 31 | export 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 | |||
| 36 | function asArray(value: unknown): unknown[] | undefined { | ||
| 37 | return Array.isArray(value) ? value : undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function asString(value: unknown): string | undefined { | ||
| 41 | return typeof value === "string" ? value : undefined; | ||
| 42 | } | ||
| 43 | |||
| 44 | function asNumber(value: unknown): number | undefined { | ||
| 45 | return typeof value === "number" && Number.isFinite(value) ? value : undefined; | ||
| 46 | } | ||
| 47 | |||
| 48 | export 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 | |||
| 56 | export 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 | |||
| 69 | export 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 | |||
| 81 | export interface WorkLogEntry { | 14 | export interface WorkLogEntry { |
| 82 | id: string; | 15 | id: string; |
| 83 | createdAt: string; | 16 | createdAt: string; |
| ... | @@ -107,680 +40,94 @@ export type TimelineEntry = | ... | @@ -107,680 +40,94 @@ export type TimelineEntry = |
| 107 | entry: WorkLogEntry; | 40 | entry: WorkLogEntry; |
| 108 | }; | 41 | }; |
| 109 | 42 | ||
| 110 | export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; | 43 | export function formatTimestamp(isoDate: string): string { |
| 111 | 44 | return new Intl.DateTimeFormat(undefined, { | |
| 112 | function normalizeDetail(value: string | undefined): string | undefined { | 45 | hour: "numeric", |
| 113 | if (!value) return undefined; | 46 | minute: "2-digit", |
| 114 | const trimmed = value.trim(); | 47 | second: "2-digit", |
| 115 | return trimmed.length > 0 ? trimmed : undefined; | 48 | }).format(new Date(isoDate)); |
| 116 | } | ||
| 117 | |||
| 118 | function 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 | |||
| 125 | export 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 | |||
| 164 | function 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 | |||
| 174 | function 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 | |||
| 182 | function shouldShowItemLifecycle(type: string): boolean { | ||
| 183 | return type.includes("tool") || type.includes("command") || type.includes("file change"); | ||
| 184 | } | ||
| 185 | |||
| 186 | function shouldDropMethod(method: string): boolean { | ||
| 187 | return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); | ||
| 188 | } | ||
| 189 | |||
| 190 | function 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 | |||
| 206 | interface 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 | |||
| 216 | function 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 | |||
| 240 | function 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 | }; | ||
| 269 | } | 49 | } |
| 270 | 50 | ||
| 271 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | 51 | export function formatDuration(durationMs: number): string { |
| 272 | if (event.kind !== "request") return null; | 52 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; |
| 273 | if (shouldDropMethod(event.method)) return null; | 53 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; |
| 274 | 54 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; | |
| 275 | if (event.method.includes("commandExecution")) { | 55 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; |
| 276 | return { | 56 | const minutes = Math.floor(durationMs / 60_000); |
| 277 | id: event.id, | 57 | const seconds = Math.round((durationMs % 60_000) / 1_000); |
| 278 | createdAt: event.createdAt, | 58 | if (seconds === 0) return `${minutes}m`; |
| 279 | label: "Command approval requested", | 59 | if (seconds === 60) return `${minutes + 1}m`; |
| 280 | tone: "tool", | 60 | return `${minutes}m ${seconds}s`; |
| 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 | }; | ||
| 308 | } | 61 | } |
| 309 | 62 | ||
| 310 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | 63 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { |
| 311 | if (event.kind !== "notification") return null; | 64 | if (!endIso) return null; |
| 312 | if (shouldDropMethod(event.method)) return null; | 65 | const startedAt = Date.parse(startIso); |
| 313 | if (event.method === "item/agentMessage/delta") return null; | 66 | const endedAt = Date.parse(endIso); |
| 314 | if (event.method === "turn/started") { | 67 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { |
| 315 | return null; | 68 | return null; |
| 316 | } | 69 | } |
| 317 | if (event.method === "thread/started") return null; | 70 | return formatDuration(endedAt - startedAt); |
| 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 | |||
| 344 | function 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 | }; | ||
| 356 | } | 71 | } |
| 357 | 72 | ||
| 358 | function eventTurnId(event: ProviderEvent): string | undefined { | 73 | export function derivePendingApprovals(_events: unknown[]): PendingApproval[] { |
| 359 | const payload = asObject(event.payload); | 74 | return []; |
| 360 | const turn = asObject(payload?.turn); | ||
| 361 | return event.turnId ?? asString(turn?.id); | ||
| 362 | } | 75 | } |
| 363 | 76 | ||
| 364 | export function deriveWorkLogEntries( | 77 | export function deriveWorkLogEntries( |
| 365 | events: ProviderEvent[], | 78 | _events: unknown[], |
| 366 | turnId: string | undefined, | 79 | _latestTurnId: string | undefined, |
| 367 | ): WorkLogEntry[] { | 80 | ): WorkLogEntry[] { |
| 368 | const ordered = [...events].toReversed(); | 81 | return []; |
| 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 | |||
| 451 | interface 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 | |||
| 460 | function 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 | |||
| 496 | function 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 | } | ||
| 515 | } | 82 | } |
| 516 | 83 | ||
| 517 | function parsePathFromDiff(diff: string): string | undefined { | 84 | export function deriveTimelineEntries( |
| 518 | const normalized = diff.replace(/\r\n/g, "\n"); | 85 | messages: ChatMessage[], |
| 519 | const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); | 86 | workEntries: WorkLogEntry[], |
| 520 | if (bPath && bPath[1]) return bPath[1]; | 87 | ): TimelineEntry[] { |
| 521 | const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); | 88 | const messageRows: TimelineEntry[] = messages.map((message) => ({ |
| 522 | if (gitHeader && gitHeader[1]) return gitHeader[1]; | 89 | id: message.id, |
| 523 | const direct = normalized.match(/^\+\+\+ (.+)$/m); | 90 | kind: "message", |
| 524 | if (!direct || !direct[1] || direct[1] === "/dev/null") { | 91 | createdAt: message.createdAt, |
| 525 | return undefined; | 92 | message, |
| 526 | } | 93 | })); |
| 527 | return direct[1]; | 94 | const workRows: TimelineEntry[] = workEntries.map((entry) => ({ |
| 528 | } | 95 | id: entry.id, |
| 529 | 96 | kind: "work", | |
| 530 | export function splitUnifiedDiffByFile(diff: string): Map<string, string> { | 97 | createdAt: entry.createdAt, |
| 531 | const normalized = diff.replace(/\r\n/g, "\n"); | 98 | entry, |
| 532 | const byPath = new Map<string, string>(); | 99 | })); |
| 533 | const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; | 100 | return [...messageRows, ...workRows].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| 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; | ||
| 555 | } | 101 | } |
| 556 | 102 | ||
| 557 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | 103 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { |
| 558 | const fileDiffsByPath = splitUnifiedDiffByFile(diff); | 104 | const files: TurnDiffFileChange[] = []; |
| 559 | return Array.from(fileDiffsByPath.entries()) | 105 | const lines = diff.split("\n"); |
| 560 | .map(([path, fileDiff]) => { | 106 | for (const line of lines) { |
| 561 | const stat = countDiffStat(fileDiff); | 107 | if (!line.startsWith("diff --git ")) continue; |
| 562 | return { | 108 | const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); |
| 563 | path, | 109 | const filePath = match?.[2] ?? match?.[1]; |
| 564 | additions: stat.additions, | 110 | if (!filePath) continue; |
| 565 | deletions: stat.deletions, | 111 | files.push({ path: filePath }); |
| 566 | }; | 112 | } |
| 567 | }) | 113 | return files; |
| 568 | .toSorted((a, b) => a.path.localeCompare(b.path)); | ||
| 569 | } | 114 | } |
| 570 | 115 | ||
| 571 | export function inferCheckpointTurnCountByTurnId( | 116 | export function inferCheckpointTurnCountByTurnId( |
| 572 | summaries: TurnDiffSummary[], | 117 | summaries: TurnDiffSummary[], |
| 573 | ): Record<string, number> { | 118 | ): Record<string, number> { |
| 574 | if (summaries.length === 0) { | 119 | const sorted = [...summaries].sort((a, b) => a.completedAt.localeCompare(b.completedAt)); |
| 575 | return {}; | 120 | const result: Record<string, number> = {}; |
| 576 | } | 121 | for (let index = 0; index < sorted.length; index += 1) { |
| 577 | 122 | const summary = sorted[index]; | |
| 578 | const sorted = [...summaries].toSorted((a, b) => { | 123 | if (!summary) continue; |
| 579 | const aTime = Date.parse(a.completedAt); | 124 | result[summary.turnId] = index + 1; |
| 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; | ||
| 597 | } | 125 | } |
| 598 | 126 | return result; | |
| 599 | return next; | ||
| 600 | } | 127 | } |
| 601 | 128 | ||
| 602 | export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { | 129 | export function deriveTurnDiffSummaries(_events: unknown[]): TurnDiffSummary[] { |
| 603 | if (!patch) { | 130 | return []; |
| 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 | |||
| 623 | export 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 | |||
| 717 | function toTimestamp(isoDate: string): number { | ||
| 718 | const parsed = Date.parse(isoDate); | ||
| 719 | return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; | ||
| 720 | } | ||
| 721 | |||
| 722 | export 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; | ||
| 784 | } | 131 | } |
| 785 | 132 | ||
| 786 | export function derivePhase(session: ProviderSession | null): SessionPhase { | 133 | export function derivePhase(session: ProviderSession | null): SessionPhase { |
| ... | @@ -789,162 +136,3 @@ export function derivePhase(session: ProviderSession | null): SessionPhase { | ... | @@ -789,162 +136,3 @@ export function derivePhase(session: ProviderSession | null): SessionPhase { |
| 789 | if (session.status === "running") return "running"; | 136 | if (session.status === "running") return "running"; |
| 790 | return "ready"; | 137 | return "ready"; |
| 791 | } | 138 | } |
| 792 | |||
| 793 | export 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 | |||
| 850 | export 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 @@ | ||
| 1 | import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { type AppState, reducer } from "./store"; | ||
| 5 | import { | ||
| 6 | DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 7 | DEFAULT_THREAD_TERMINAL_ID, | ||
| 8 | MAX_THREAD_TERMINAL_COUNT, | ||
| 9 | } from "./types"; | ||
| 10 | import type { Thread } from "./types"; | ||
| 11 | |||
| 12 | type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>; | ||
| 13 | type TerminalActivityEvent = Extract<TerminalEvent, { type: "activity" }>; | ||
| 14 | |||
| 15 | function 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 | |||
| 26 | function 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 | |||
| 38 | function 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 | |||
| 69 | function 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 | |||
| 87 | function 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 | |||
| 110 | function 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 | |||
| 123 | describe("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 { | ... | @@ -8,19 +8,13 @@ import { |
| 8 | useReducer, | 8 | useReducer, |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | 10 | ||
| 11 | import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; | ||
| 12 | import { resolveModelSlug } from "./model-logic"; | ||
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | ||
| 14 | import { | 11 | import { |
| 15 | applyEventToMessages, | 12 | type OrchestrationReadModel, |
| 16 | asObject, | 13 | normalizeProjectScripts, |
| 17 | asString, | 14 | } from "@t3tools/contracts"; |
| 18 | deriveTurnDiffSummaries, | 15 | import { resolveModelSlug } from "./model-logic"; |
| 19 | inferCheckpointTurnCountByTurnId, | ||
| 20 | evolveSession, | ||
| 21 | } from "./session-logic"; | ||
| 22 | import { | 16 | import { |
| 23 | type ChatAttachment, | 17 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 24 | DEFAULT_THREAD_TERMINAL_ID, | 18 | DEFAULT_THREAD_TERMINAL_ID, |
| 25 | DEFAULT_RUNTIME_MODE, | 19 | DEFAULT_RUNTIME_MODE, |
| 26 | MAX_THREAD_TERMINAL_COUNT, | 20 | MAX_THREAD_TERMINAL_COUNT, |
| ... | @@ -34,13 +28,10 @@ import { | ... | @@ -34,13 +28,10 @@ import { |
| 34 | // ── Actions ────────────────────────────────────────────────────────── | 28 | // ── Actions ────────────────────────────────────────────────────────── |
| 35 | 29 | ||
| 36 | type Action = | 30 | type Action = |
| 37 | | { type: "ADD_PROJECT"; project: Project } | 31 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 38 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | 32 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } |
| 39 | | { type: "SYNC_PROJECTS"; projects: Project[] } | ||
| 40 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | 33 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } |
| 41 | | { type: "TOGGLE_PROJECT"; projectId: string } | 34 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| 42 | | { type: "DELETE_PROJECT"; projectId: string } | ||
| 43 | | { type: "ADD_THREAD"; thread: Thread } | ||
| 44 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | 35 | | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } |
| 45 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | 36 | | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } |
| 46 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | 37 | | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } |
| ... | @@ -48,46 +39,14 @@ type Action = | ... | @@ -48,46 +39,14 @@ type Action = |
| 48 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | 39 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 49 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | 40 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } |
| 50 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | 41 | | { 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 | } | ||
| 66 | | { type: "SET_ERROR"; threadId: string; error: string | null } | 42 | | { 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 | } | ||
| 82 | | { | 43 | | { |
| 83 | type: "SET_THREAD_BRANCH"; | 44 | type: "SET_THREAD_BRANCH"; |
| 84 | threadId: string; | 45 | threadId: string; |
| 85 | branch: string | null; | 46 | branch: string | null; |
| 86 | worktreePath: string | null; | 47 | worktreePath: string | null; |
| 87 | } | 48 | } |
| 88 | | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | 49 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; |
| 89 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } | ||
| 90 | | { type: "DELETE_THREAD"; threadId: string }; | ||
| 91 | 50 | ||
| 92 | // ── State ──────────────────────────────────────────────────────────── | 51 | // ── State ──────────────────────────────────────────────────────────── |
| 93 | 52 | ||
| ... | @@ -116,6 +75,7 @@ const initialState: AppState = { | ... | @@ -116,6 +75,7 @@ const initialState: AppState = { |
| 116 | threadsHydrated: false, | 75 | threadsHydrated: false, |
| 117 | runtimeMode: DEFAULT_RUNTIME_MODE, | 76 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 118 | }; | 77 | }; |
| 78 | const persistedExpandedProjectCwds = new Set<string>(); | ||
| 119 | 79 | ||
| 120 | // ── Helpers ────────────────────────────────────────────────────────── | 80 | // ── Helpers ────────────────────────────────────────────────────────── |
| 121 | 81 | ||
| ... | @@ -123,21 +83,24 @@ function readPersistedState(): AppState { | ... | @@ -123,21 +83,24 @@ function readPersistedState(): AppState { |
| 123 | if (typeof window === "undefined") return initialState; | 83 | if (typeof window === "undefined") return initialState; |
| 124 | 84 | ||
| 125 | try { | 85 | try { |
| 126 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); | 86 | const raw = 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; | ||
| 130 | if (!raw) return initialState; | 87 | if (!raw) return initialState; |
| 131 | const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1"); | 88 | const parsed = JSON.parse(raw) as { |
| 132 | const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); | 89 | runtimeMode?: RuntimeMode; |
| 133 | if (!hydrated) return initialState; | 90 | expandedProjectCwds?: string[]; |
| 134 | 91 | }; | |
| 135 | const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); | 92 | persistedExpandedProjectCwds.clear(); |
| 136 | 93 | for (const cwd of parsed.expandedProjectCwds ?? []) { | |
| 94 | if (typeof cwd === "string" && cwd.length > 0) { | ||
| 95 | persistedExpandedProjectCwds.add(cwd); | ||
| 96 | } | ||
| 97 | } | ||
| 137 | return { | 98 | return { |
| 138 | ...hydrated, | 99 | ...initialState, |
| 139 | threads, | 100 | runtimeMode: |
| 140 | threadsHydrated: threads.length > 0, | 101 | parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" |
| 102 | ? parsed.runtimeMode | ||
| 103 | : DEFAULT_RUNTIME_MODE, | ||
| 141 | }; | 104 | }; |
| 142 | } catch { | 105 | } catch { |
| 143 | return initialState; | 106 | return initialState; |
| ... | @@ -148,7 +111,15 @@ function persistState(state: AppState): void { | ... | @@ -148,7 +111,15 @@ function persistState(state: AppState): void { |
| 148 | if (typeof window === "undefined") return; | 111 | if (typeof window === "undefined") return; |
| 149 | 112 | ||
| 150 | try { | 113 | 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 | ); | ||
| 152 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | 123 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| 153 | window.localStorage.removeItem(legacyKey); | 124 | window.localStorage.removeItem(legacyKey); |
| 154 | } | 125 | } |
| ... | @@ -165,63 +136,6 @@ function updateThread( | ... | @@ -165,63 +136,6 @@ function updateThread( |
| 165 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); | 136 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 166 | } | 137 | } |
| 167 | 138 | ||
| 168 | function 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 | } | ||
| 225 | 139 | ||
| 226 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 140 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 227 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | 141 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| ... | @@ -409,108 +323,80 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { | ... | @@ -409,108 +323,80 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 409 | }); | 323 | }); |
| 410 | } | 324 | } |
| 411 | 325 | ||
| 412 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { | ||
| 413 | return threads.find((t) => t.session?.sessionId === sessionId); | ||
| 414 | } | ||
| 415 | |||
| 416 | function 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 | |||
| 423 | function 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 | |||
| 437 | function 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 | |||
| 456 | function 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 | |||
| 466 | function 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 | } | ||
| 494 | 326 | ||
| 495 | // ── Reducer ────────────────────────────────────────────────────────── | 327 | // ── Reducer ────────────────────────────────────────────────────────── |
| 496 | 328 | ||
| 497 | export function reducer(state: AppState, action: Action): AppState { | 329 | export function reducer(state: AppState, action: Action): AppState { |
| 498 | switch (action.type) { | 330 | switch (action.type) { |
| 499 | case "ADD_PROJECT": | 331 | case "SYNC_SERVER_READ_MODEL": { |
| 500 | if (state.projects.some((project) => project.cwd === action.project.cwd)) { | 332 | const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); |
| 501 | return state; | 333 | const projects = action.readModel.projects.map((project) => { |
| 502 | } | 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 | }); | ||
| 503 | return { | 393 | return { |
| 504 | ...state, | 394 | ...state, |
| 505 | projects: [ | 395 | projects, |
| 506 | ...state.projects, | 396 | threads, |
| 507 | { | 397 | threadsHydrated: true, |
| 508 | ...action.project, | ||
| 509 | model: resolveModelSlug(action.project.model), | ||
| 510 | scripts: normalizeProjectScripts(action.project.scripts), | ||
| 511 | }, | ||
| 512 | ], | ||
| 513 | }; | 398 | }; |
| 399 | } | ||
| 514 | 400 | ||
| 515 | case "SET_PROJECT_SCRIPTS": | 401 | case "SET_PROJECT_SCRIPTS": |
| 516 | return { | 402 | return { |
| ... | @@ -531,46 +417,6 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -531,46 +417,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 531 | threadsHydrated: action.hydrated, | 417 | threadsHydrated: action.hydrated, |
| 532 | }; | 418 | }; |
| 533 | 419 | ||
| 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 | |||
| 574 | case "TOGGLE_PROJECT": | 420 | case "TOGGLE_PROJECT": |
| 575 | return { | 421 | return { |
| 576 | ...state, | 422 | ...state, |
| ... | @@ -579,34 +425,6 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -579,34 +425,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 579 | ), | 425 | ), |
| 580 | }; | 426 | }; |
| 581 | 427 | ||
| 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 | |||
| 610 | case "TOGGLE_THREAD_TERMINAL": | 428 | case "TOGGLE_THREAD_TERMINAL": |
| 611 | return { | 429 | return { |
| 612 | ...state, | 430 | ...state, |
| ... | @@ -774,118 +592,6 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -774,118 +592,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 774 | ), | 592 | ), |
| 775 | }; | 593 | }; |
| 776 | 594 | ||
| 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 | |||
| 889 | case "SET_ERROR": | 595 | case "SET_ERROR": |
| 890 | return { | 596 | return { |
| 891 | ...state, | 597 | ...state, |
| ... | @@ -895,90 +601,6 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -895,90 +601,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 895 | })), | 601 | })), |
| 896 | }; | 602 | }; |
| 897 | 603 | ||
| 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 | |||
| 982 | case "SET_THREAD_BRANCH": { | 604 | case "SET_THREAD_BRANCH": { |
| 983 | return { | 605 | return { |
| 984 | ...state, | 606 | ...state, |
| ... | @@ -997,29 +619,12 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -997,29 +619,12 @@ export function reducer(state: AppState, action: Action): AppState { |
| 997 | }; | 619 | }; |
| 998 | } | 620 | } |
| 999 | 621 | ||
| 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 | |||
| 1011 | case "SET_RUNTIME_MODE": | 622 | case "SET_RUNTIME_MODE": |
| 1012 | return { | 623 | return { |
| 1013 | ...state, | 624 | ...state, |
| 1014 | runtimeMode: action.mode, | 625 | runtimeMode: action.mode, |
| 1015 | }; | 626 | }; |
| 1016 | 627 | ||
| 1017 | case "DELETE_THREAD": | ||
| 1018 | return { | ||
| 1019 | ...state, | ||
| 1020 | threads: state.threads.filter((t) => t.id !== action.threadId), | ||
| 1021 | }; | ||
| 1022 | |||
| 1023 | default: | 628 | default: |
| 1024 | return state; | 629 | return state; |
| 1025 | } | 630 | } |
apps/web/src/threadFactory.ts-1| ... | @@ -32,7 +32,6 @@ export function createThread(projectId: string, options: CreateThreadOptions = { | ... | @@ -32,7 +32,6 @@ export function createThread(projectId: string, options: CreateThreadOptions = { |
| 32 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 32 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 33 | session: null, | 33 | session: null, |
| 34 | messages: [], | 34 | messages: [], |
| 35 | events: [], | ||
| 36 | turnDiffSummaries: [], | 35 | turnDiffSummaries: [], |
| 37 | error: null, | 36 | error: null, |
| 38 | createdAt, | 37 | createdAt, |
apps/web/src/types.ts-2| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ProjectScript as ContractProjectScript, | 2 | ProjectScript as ContractProjectScript, |
| 3 | ProviderEvent, | ||
| 4 | ProviderSession, | 3 | ProviderSession, |
| 5 | } from "@t3tools/contracts"; | 4 | } from "@t3tools/contracts"; |
| 6 | 5 | ||
| ... | @@ -77,7 +76,6 @@ export interface Thread { | ... | @@ -77,7 +76,6 @@ export interface Thread { |
| 77 | activeTerminalGroupId: string; | 76 | activeTerminalGroupId: string; |
| 78 | session: ProviderSession | null; | 77 | session: ProviderSession | null; |
| 79 | messages: ChatMessage[]; | 78 | messages: ChatMessage[]; |
| 80 | events: ProviderEvent[]; | ||
| 81 | error: string | null; | 79 | error: string | null; |
| 82 | createdAt: string; | 80 | createdAt: string; |
| 83 | latestTurnId?: string | undefined; | 81 | latestTurnId?: string | undefined; |
apps/web/src/worktreeCleanup.test.ts-1| ... | @@ -24,7 +24,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -24,7 +24,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 24 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 24 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 25 | session: null, | 25 | session: null, |
| 26 | messages: [], | 26 | messages: [], |
| 27 | events: [], | ||
| 28 | turnDiffSummaries: [], | 27 | turnDiffSummaries: [], |
| 29 | error: null, | 28 | error: null, |
| 30 | createdAt: "2026-02-13T00:00:00.000Z", | 29 | createdAt: "2026-02-13T00:00:00.000Z", |
apps/web/src/wsNativeApi.ts+30-4| ... | @@ -1,4 +1,13 @@ | ... | @@ -1,4 +1,13 @@ |
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; | 1 | import { |
| 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"; | ||
| 2 | 11 | ||
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | 12 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 4 | import { WsTransport } from "./wsTransport"; | 13 | import { WsTransport } from "./wsTransport"; |
| ... | @@ -90,13 +99,10 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -90,13 +99,10 @@ export function createWsNativeApi(): NativeApi { |
| 90 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), | 99 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), |
| 91 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | 100 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), |
| 92 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | 101 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), |
| 93 | listSessions: () => transport.request(WS_METHODS.providersListSessions), | ||
| 94 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), | 102 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), |
| 95 | getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), | 103 | getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), |
| 96 | revertToCheckpoint: (input) => | 104 | revertToCheckpoint: (input) => |
| 97 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), | 105 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), |
| 98 | onEvent: (callback) => | ||
| 99 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), | ||
| 100 | }, | 106 | }, |
| 101 | projects: { | 107 | projects: { |
| 102 | list: () => transport.request(WS_METHODS.projectsList), | 108 | list: () => transport.request(WS_METHODS.projectsList), |
| ... | @@ -149,6 +155,26 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -149,6 +155,26 @@ export function createWsNativeApi(): NativeApi { |
| 149 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), | 155 | getConfig: () => transport.request(WS_METHODS.serverGetConfig), |
| 150 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), | 156 | upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), |
| 151 | }, | 157 | }, |
| 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 | }, | ||
| 152 | }; | 178 | }; |
| 153 | 179 | ||
| 154 | instance = { api, transport }; | 180 | instance = { api, transport }; |
bun.lock+74| ... | @@ -34,6 +34,8 @@ | ... | @@ -34,6 +34,8 @@ |
| 34 | "t3": "./dist/index.mjs", | 34 | "t3": "./dist/index.mjs", |
| 35 | }, | 35 | }, |
| 36 | "dependencies": { | 36 | "dependencies": { |
| 37 | "better-sqlite3": "^12.6.2", | ||
| 38 | "effect": "^3.19.19", | ||
| 37 | "node-pty": "^1.1.0", | 39 | "node-pty": "^1.1.0", |
| 38 | "open": "^10.1.0", | 40 | "open": "^10.1.0", |
| 39 | "ws": "^8.18.0", | 41 | "ws": "^8.18.0", |
| ... | @@ -41,6 +43,7 @@ | ... | @@ -41,6 +43,7 @@ |
| 41 | "devDependencies": { | 43 | "devDependencies": { |
| 42 | "@t3tools/contracts": "workspace:*", | 44 | "@t3tools/contracts": "workspace:*", |
| 43 | "@t3tools/web": "workspace:*", | 45 | "@t3tools/web": "workspace:*", |
| 46 | "@types/better-sqlite3": "^7.6.13", | ||
| 44 | "@types/node": "^22.10.2", | 47 | "@types/node": "^22.10.2", |
| 45 | "@types/ws": "^8.5.13", | 48 | "@types/ws": "^8.5.13", |
| 46 | "tsdown": "^0.20.3", | 49 | "tsdown": "^0.20.3", |
| ... | @@ -87,6 +90,7 @@ | ... | @@ -87,6 +90,7 @@ |
| 87 | "name": "@t3tools/contracts", | 90 | "name": "@t3tools/contracts", |
| 88 | "version": "0.0.0", | 91 | "version": "0.0.0", |
| 89 | "dependencies": { | 92 | "dependencies": { |
| 93 | "effect": "^3.19.19", | ||
| 90 | "zod": "^3.24.1", | 94 | "zod": "^3.24.1", |
| 91 | }, | 95 | }, |
| 92 | "devDependencies": { | 96 | "devDependencies": { |
| ... | @@ -494,6 +498,8 @@ | ... | @@ -494,6 +498,8 @@ |
| 494 | 498 | ||
| 495 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], | 499 | "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], |
| 496 | 500 | ||
| 501 | "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], | ||
| 502 | |||
| 497 | "@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=="], | 503 | "@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=="], |
| 498 | 504 | ||
| 499 | "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], | 505 | "@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 @@ | ... | @@ -582,18 +588,28 @@ |
| 582 | 588 | ||
| 583 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], | 589 | "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], |
| 584 | 590 | ||
| 591 | "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], | ||
| 592 | |||
| 585 | "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], | 593 | "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], |
| 586 | 594 | ||
| 595 | "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], | ||
| 596 | |||
| 587 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], | 597 | "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], |
| 588 | 598 | ||
| 599 | "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], | ||
| 600 | |||
| 589 | "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], | 601 | "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], |
| 590 | 602 | ||
| 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 | |||
| 591 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], | 605 | "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], |
| 592 | 606 | ||
| 593 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], | 607 | "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], |
| 594 | 608 | ||
| 595 | "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=="], | 609 | "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=="], |
| 596 | 610 | ||
| 611 | "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], | ||
| 612 | |||
| 597 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], | 613 | "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], |
| 598 | 614 | ||
| 599 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], | 615 | "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], |
| ... | @@ -624,6 +640,8 @@ | ... | @@ -624,6 +640,8 @@ |
| 624 | 640 | ||
| 625 | "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=="], | 641 | "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=="], |
| 626 | 642 | ||
| 643 | "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], | ||
| 644 | |||
| 627 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], | 645 | "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], |
| 628 | 646 | ||
| 629 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], | 647 | "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], |
| ... | @@ -656,6 +674,8 @@ | ... | @@ -656,6 +674,8 @@ |
| 656 | 674 | ||
| 657 | "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], | 675 | "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], |
| 658 | 676 | ||
| 677 | "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], | ||
| 678 | |||
| 659 | "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], | 679 | "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], |
| 660 | 680 | ||
| 661 | "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], | 681 | "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], |
| ... | @@ -686,6 +706,8 @@ | ... | @@ -686,6 +706,8 @@ |
| 686 | 706 | ||
| 687 | "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=="], | 707 | "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=="], |
| 688 | 708 | ||
| 709 | "effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], | ||
| 710 | |||
| 689 | "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=="], | 711 | "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=="], |
| 690 | 712 | ||
| 691 | "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], | 713 | "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], |
| ... | @@ -726,22 +748,30 @@ | ... | @@ -726,22 +748,30 @@ |
| 726 | 748 | ||
| 727 | "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], | 749 | "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], |
| 728 | 750 | ||
| 751 | "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], | ||
| 752 | |||
| 729 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], | 753 | "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], |
| 730 | 754 | ||
| 731 | "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], | 755 | "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], |
| 732 | 756 | ||
| 733 | "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=="], | 757 | "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=="], |
| 734 | 758 | ||
| 759 | "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], | ||
| 760 | |||
| 735 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], | 761 | "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], |
| 736 | 762 | ||
| 737 | "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], | 763 | "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], |
| 738 | 764 | ||
| 765 | "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], | ||
| 766 | |||
| 739 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], | 767 | "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], |
| 740 | 768 | ||
| 741 | "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], | 769 | "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], |
| 742 | 770 | ||
| 743 | "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=="], | 771 | "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=="], |
| 744 | 772 | ||
| 773 | "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], | ||
| 774 | |||
| 745 | "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=="], | 775 | "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=="], |
| 746 | 776 | ||
| 747 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], | 777 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], |
| ... | @@ -760,6 +790,8 @@ | ... | @@ -760,6 +790,8 @@ |
| 760 | 790 | ||
| 761 | "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], | 791 | "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], |
| 762 | 792 | ||
| 793 | "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], | ||
| 794 | |||
| 763 | "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], | 795 | "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], |
| 764 | 796 | ||
| 765 | "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=="], | 797 | "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 @@ | ... | @@ -808,10 +840,16 @@ |
| 808 | 840 | ||
| 809 | "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], | 841 | "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], |
| 810 | 842 | ||
| 843 | "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], | ||
| 844 | |||
| 811 | "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], | 845 | "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], |
| 812 | 846 | ||
| 813 | "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], | 847 | "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], |
| 814 | 848 | ||
| 849 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], | ||
| 850 | |||
| 851 | "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], | ||
| 852 | |||
| 815 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], | 853 | "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], |
| 816 | 854 | ||
| 817 | "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], | 855 | "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], |
| ... | @@ -1006,6 +1044,8 @@ | ... | @@ -1006,6 +1044,8 @@ |
| 1006 | 1044 | ||
| 1007 | "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], | 1045 | "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], |
| 1008 | 1046 | ||
| 1047 | "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], | ||
| 1048 | |||
| 1009 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], | 1049 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], |
| 1010 | 1050 | ||
| 1011 | "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=="], | 1051 | "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 @@ | ... | @@ -1014,6 +1054,10 @@ |
| 1014 | 1054 | ||
| 1015 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], | 1055 | "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 1016 | 1056 | ||
| 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 | |||
| 1017 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], | 1061 | "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], |
| 1018 | 1062 | ||
| 1019 | "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], | 1063 | "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], |
| ... | @@ -1060,6 +1104,8 @@ | ... | @@ -1060,6 +1104,8 @@ |
| 1060 | 1104 | ||
| 1061 | "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=="], | 1105 | "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=="], |
| 1062 | 1106 | ||
| 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 | |||
| 1063 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], | 1109 | "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], |
| 1064 | 1110 | ||
| 1065 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], | 1111 | "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], |
| ... | @@ -1070,10 +1116,14 @@ | ... | @@ -1070,10 +1116,14 @@ |
| 1070 | 1116 | ||
| 1071 | "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], | 1117 | "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], |
| 1072 | 1118 | ||
| 1119 | "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], | ||
| 1120 | |||
| 1073 | "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], | 1121 | "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], |
| 1074 | 1122 | ||
| 1075 | "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], | 1123 | "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], |
| 1076 | 1124 | ||
| 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 | |||
| 1077 | "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], | 1127 | "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], |
| 1078 | 1128 | ||
| 1079 | "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], | 1129 | "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 @@ | ... | @@ -1082,6 +1132,8 @@ |
| 1082 | 1132 | ||
| 1083 | "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], | 1133 | "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], |
| 1084 | 1134 | ||
| 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 | |||
| 1085 | "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | 1137 | "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], |
| 1086 | 1138 | ||
| 1087 | "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=="], | 1139 | "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 @@ | ... | @@ -1132,6 +1184,8 @@ |
| 1132 | 1184 | ||
| 1133 | "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], | 1185 | "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], |
| 1134 | 1186 | ||
| 1187 | "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], | ||
| 1188 | |||
| 1135 | "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], | 1189 | "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], |
| 1136 | 1190 | ||
| 1137 | "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], | 1191 | "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], |
| ... | @@ -1150,6 +1204,10 @@ | ... | @@ -1150,6 +1204,10 @@ |
| 1150 | 1204 | ||
| 1151 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], | 1205 | "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], |
| 1152 | 1206 | ||
| 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 | |||
| 1153 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], | 1211 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], |
| 1154 | 1212 | ||
| 1155 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], | 1213 | "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], |
| ... | @@ -1168,10 +1226,14 @@ | ... | @@ -1168,10 +1226,14 @@ |
| 1168 | 1226 | ||
| 1169 | "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=="], | 1227 | "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=="], |
| 1170 | 1228 | ||
| 1229 | "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], | ||
| 1230 | |||
| 1171 | "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=="], | 1231 | "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=="], |
| 1172 | 1232 | ||
| 1173 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], | 1233 | "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], |
| 1174 | 1234 | ||
| 1235 | "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], | ||
| 1236 | |||
| 1175 | "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], | 1237 | "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], |
| 1176 | 1238 | ||
| 1177 | "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], | 1239 | "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], |
| ... | @@ -1192,6 +1254,10 @@ | ... | @@ -1192,6 +1254,10 @@ |
| 1192 | 1254 | ||
| 1193 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], | 1255 | "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], |
| 1194 | 1256 | ||
| 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 | |||
| 1195 | "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], | 1261 | "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], |
| 1196 | 1262 | ||
| 1197 | "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], | 1263 | "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], |
| ... | @@ -1226,6 +1292,8 @@ | ... | @@ -1226,6 +1292,8 @@ |
| 1226 | 1292 | ||
| 1227 | "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=="], | 1293 | "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=="], |
| 1228 | 1294 | ||
| 1295 | "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], | ||
| 1296 | |||
| 1229 | "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=="], | 1297 | "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=="], |
| 1230 | 1298 | ||
| 1231 | "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], | 1299 | "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], |
| ... | @@ -1276,6 +1344,8 @@ | ... | @@ -1276,6 +1344,8 @@ |
| 1276 | 1344 | ||
| 1277 | "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=="], | 1345 | "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=="], |
| 1278 | 1346 | ||
| 1347 | "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], | ||
| 1348 | |||
| 1279 | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], | 1349 | "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], |
| 1280 | 1350 | ||
| 1281 | "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], | 1351 | "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 @@ | ... | @@ -1366,6 +1436,8 @@ |
| 1366 | 1436 | ||
| 1367 | "@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=="], | 1437 | "@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=="], |
| 1368 | 1438 | ||
| 1439 | "@types/better-sqlite3/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], | ||
| 1440 | |||
| 1369 | "@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=="], | 1441 | "@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=="], |
| 1370 | 1442 | ||
| 1371 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | 1443 | "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], |
| ... | @@ -1422,6 +1494,8 @@ | ... | @@ -1422,6 +1494,8 @@ |
| 1422 | 1494 | ||
| 1423 | "@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=="], | 1495 | "@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=="], |
| 1424 | 1496 | ||
| 1497 | "@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | ||
| 1498 | |||
| 1425 | "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], | 1499 | "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], |
| 1426 | 1500 | ||
| 1427 | "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], | 1501 | "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 @@ | ... | @@ -23,6 +23,7 @@ |
| 23 | "test": "vitest run" | 23 | "test": "vitest run" |
| 24 | }, | 24 | }, |
| 25 | "dependencies": { | 25 | "dependencies": { |
| 26 | "effect": "^3.19.19", | ||
| 26 | "zod": "^3.24.1" | 27 | "zod": "^3.24.1" |
| 27 | }, | 28 | }, |
| 28 | "devDependencies": { | 29 | "devDependencies": { |
packages/contracts/src/index.ts+1| ... | @@ -9,3 +9,4 @@ export * from "./keybindings"; | ... | @@ -9,3 +9,4 @@ export * from "./keybindings"; |
| 9 | export * from "./server"; | 9 | export * from "./server"; |
| 10 | export * from "./project"; | 10 | export * from "./project"; |
| 11 | export * from "./git"; | 11 | export * from "./git"; |
| 12 | export * from "./orchestration"; |
packages/contracts/src/ipc.ts+12-3| ... | @@ -16,7 +16,6 @@ import type { | ... | @@ -16,7 +16,6 @@ import type { |
| 16 | GitStatusResult, | 16 | GitStatusResult, |
| 17 | } from "./git"; | 17 | } from "./git"; |
| 18 | import type { | 18 | import type { |
| 19 | ProviderEvent, | ||
| 20 | ProviderGetCheckpointDiffInput, | 19 | ProviderGetCheckpointDiffInput, |
| 21 | ProviderGetCheckpointDiffResult, | 20 | ProviderGetCheckpointDiffResult, |
| 22 | ProviderInterruptTurnInput, | 21 | ProviderInterruptTurnInput, |
| ... | @@ -53,6 +52,11 @@ import type { | ... | @@ -53,6 +52,11 @@ import type { |
| 53 | } from "./terminal"; | 52 | } from "./terminal"; |
| 54 | import type { NewTodoInput, Todo } from "./todo"; | 53 | import type { NewTodoInput, Todo } from "./todo"; |
| 55 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; | 54 | import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; |
| 55 | import type { | ||
| 56 | OrchestrationCommand, | ||
| 57 | OrchestrationEvent, | ||
| 58 | OrchestrationReadModel, | ||
| 59 | } from "./orchestration"; | ||
| 56 | 60 | ||
| 57 | export const EDITORS = [ | 61 | export const EDITORS = [ |
| 58 | { id: "cursor", label: "Cursor", command: "cursor" }, | 62 | { id: "cursor", label: "Cursor", command: "cursor" }, |
| ... | @@ -94,7 +98,6 @@ export interface NativeApi { | ... | @@ -94,7 +98,6 @@ export interface NativeApi { |
| 94 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | 98 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 95 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; | 99 | respondToRequest: (input: ProviderRespondToRequestInput) => Promise<void>; |
| 96 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | 100 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 97 | listSessions: () => Promise<ProviderSession[]>; | ||
| 98 | listCheckpoints: ( | 101 | listCheckpoints: ( |
| 99 | input: ProviderListCheckpointsInput, | 102 | input: ProviderListCheckpointsInput, |
| 100 | ) => Promise<ProviderListCheckpointsResult>; | 103 | ) => Promise<ProviderListCheckpointsResult>; |
| ... | @@ -104,7 +107,6 @@ export interface NativeApi { | ... | @@ -104,7 +107,6 @@ export interface NativeApi { |
| 104 | revertToCheckpoint: ( | 107 | revertToCheckpoint: ( |
| 105 | input: ProviderRevertToCheckpointInput, | 108 | input: ProviderRevertToCheckpointInput, |
| 106 | ) => Promise<ProviderRevertToCheckpointResult>; | 109 | ) => Promise<ProviderRevertToCheckpointResult>; |
| 107 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | ||
| 108 | }; | 110 | }; |
| 109 | projects: { | 111 | projects: { |
| 110 | list: () => Promise<ProjectListResult>; | 112 | list: () => Promise<ProjectListResult>; |
| ... | @@ -140,4 +142,11 @@ export interface NativeApi { | ... | @@ -140,4 +142,11 @@ export interface NativeApi { |
| 140 | getConfig: () => Promise<ServerConfig>; | 142 | getConfig: () => Promise<ServerConfig>; |
| 141 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | 143 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 142 | }; | 144 | }; |
| 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 | }; | ||
| 143 | } | 152 | } |
packages/contracts/src/orchestration.ts created+223| ... | @@ -0,0 +1,223 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | export const ORCHESTRATION_WS_METHODS = { | ||
| 4 | getSnapshot: "orchestration.getSnapshot", | ||
| 5 | dispatchCommand: "orchestration.dispatchCommand", | ||
| 6 | replayEvents: "orchestration.replayEvents", | ||
| 7 | } as const; | ||
| 8 | |||
| 9 | export const ORCHESTRATION_WS_CHANNELS = { | ||
| 10 | readModel: "orchestration.readModel", | ||
| 11 | domainEvent: "orchestration.domainEvent", | ||
| 12 | } as const; | ||
| 13 | |||
| 14 | const IsoDateTimeSchema = Schema.String; | ||
| 15 | const IdSchema = Schema.String; | ||
| 16 | |||
| 17 | export 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 | |||
| 25 | export type OrchestrationMessage = Schema.Schema.Type<typeof OrchestrationMessageSchema>; | ||
| 26 | |||
| 27 | export 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 | |||
| 38 | export type OrchestrationSession = Schema.Schema.Type<typeof OrchestrationSessionSchema>; | ||
| 39 | |||
| 40 | export 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 | |||
| 47 | export type OrchestrationTurnDiffFile = Schema.Schema.Type<typeof OrchestrationTurnDiffFileSchema>; | ||
| 48 | |||
| 49 | export 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 | |||
| 58 | export type OrchestrationTurnDiffSummary = Schema.Schema.Type< | ||
| 59 | typeof OrchestrationTurnDiffSummarySchema | ||
| 60 | >; | ||
| 61 | |||
| 62 | export 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 | |||
| 81 | export type OrchestrationThread = Schema.Schema.Type<typeof OrchestrationThreadSchema>; | ||
| 82 | |||
| 83 | export 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 | |||
| 92 | export type OrchestrationProject = Schema.Schema.Type<typeof OrchestrationProjectSchema>; | ||
| 93 | |||
| 94 | export 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 | |||
| 103 | export type OrchestrationGitReadModel = Schema.Schema.Type<typeof OrchestrationGitReadModelSchema>; | ||
| 104 | |||
| 105 | export 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 | |||
| 113 | export type OrchestrationReadModel = Schema.Schema.Type<typeof OrchestrationReadModelSchema>; | ||
| 114 | |||
| 115 | export 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 | |||
| 125 | export const DeleteProjectCommandSchema = Schema.Struct({ | ||
| 126 | type: Schema.Literal("project.delete"), | ||
| 127 | commandId: IdSchema, | ||
| 128 | projectId: IdSchema, | ||
| 129 | createdAt: IsoDateTimeSchema, | ||
| 130 | }); | ||
| 131 | |||
| 132 | export 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 | |||
| 144 | export const DeleteThreadCommandSchema = Schema.Struct({ | ||
| 145 | type: Schema.Literal("thread.delete"), | ||
| 146 | commandId: IdSchema, | ||
| 147 | threadId: IdSchema, | ||
| 148 | createdAt: IsoDateTimeSchema, | ||
| 149 | }); | ||
| 150 | |||
| 151 | export 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 | |||
| 162 | export 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 | |||
| 173 | export const SetThreadSessionSchema = Schema.Struct({ | ||
| 174 | type: Schema.Literal("thread.session"), | ||
| 175 | commandId: IdSchema, | ||
| 176 | threadId: IdSchema, | ||
| 177 | session: OrchestrationSessionSchema, | ||
| 178 | createdAt: IsoDateTimeSchema, | ||
| 179 | }); | ||
| 180 | |||
| 181 | export 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 | |||
| 192 | export const OrchestrationCommandSchema = Schema.Union( | ||
| 193 | CreateProjectCommandSchema, | ||
| 194 | DeleteProjectCommandSchema, | ||
| 195 | CreateThreadCommandSchema, | ||
| 196 | DeleteThreadCommandSchema, | ||
| 197 | UpdateThreadMetaCommandSchema, | ||
| 198 | SendMessageCommandSchema, | ||
| 199 | SetThreadSessionSchema, | ||
| 200 | UpsertGitReadModelCommandSchema, | ||
| 201 | ); | ||
| 202 | |||
| 203 | export type OrchestrationCommand = Schema.Schema.Type<typeof OrchestrationCommandSchema>; | ||
| 204 | |||
| 205 | export 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 | |||
| 216 | export type OrchestrationEvent = Schema.Schema.Type<typeof OrchestrationEventSchema>; | ||
| 217 | |||
| 218 | export const OrchestrationReadModelPushSchema = Schema.Struct({ | ||
| 219 | sequence: Schema.Number, | ||
| 220 | snapshot: OrchestrationReadModelSchema, | ||
| 221 | }); | ||
| 222 | |||
| 223 | export type OrchestrationReadModelPush = Schema.Schema.Type<typeof OrchestrationReadModelPushSchema>; | ||
packages/contracts/src/ws.ts-2| ... | @@ -9,7 +9,6 @@ export const WS_METHODS = { | ... | @@ -9,7 +9,6 @@ export const WS_METHODS = { |
| 9 | providersInterruptTurn: "providers.interruptTurn", | 9 | providersInterruptTurn: "providers.interruptTurn", |
| 10 | providersRespondToRequest: "providers.respondToRequest", | 10 | providersRespondToRequest: "providers.respondToRequest", |
| 11 | providersStopSession: "providers.stopSession", | 11 | providersStopSession: "providers.stopSession", |
| 12 | providersListSessions: "providers.listSessions", | ||
| 13 | providersListCheckpoints: "providers.listCheckpoints", | 12 | providersListCheckpoints: "providers.listCheckpoints", |
| 14 | providersGetCheckpointDiff: "providers.getCheckpointDiff", | 13 | providersGetCheckpointDiff: "providers.getCheckpointDiff", |
| 15 | providersRevertToCheckpoint: "providers.revertToCheckpoint", | 14 | providersRevertToCheckpoint: "providers.revertToCheckpoint", |
| ... | @@ -51,7 +50,6 @@ export const WS_METHODS = { | ... | @@ -51,7 +50,6 @@ export const WS_METHODS = { |
| 51 | // ── Push Event Channels ────────────────────────────────────────────── | 50 | // ── Push Event Channels ────────────────────────────────────────────── |
| 52 | 51 | ||
| 53 | export const WS_CHANNELS = { | 52 | export const WS_CHANNELS = { |
| 54 | providerEvent: "providers.event", | ||
| 55 | terminalEvent: "terminal.event", | 53 | terminalEvent: "terminal.event", |
| 56 | serverWelcome: "server.welcome", | 54 | serverWelcome: "server.welcome", |
| 57 | } as const; | 55 | } as const; |