diff --git a/README.md b/README.md index 425a70e4041018b3793af34d548c358597bad03d..0a863d8c42e8e0a991f8b7ca62964c8344134e9f 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,12 @@ T3 Code has a global runtime mode switch in the chat toolbar: The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` -- **Push events**: `{ type: "push", channel, data }` for streaming provider events +- **Push events**: `{ type: "push", channel, data }` for orchestration read-model updates Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` -- `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` +- `providers.respondToRequest`, `providers.stopSession` - `shell.openInEditor`, `server.getConfig` Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. diff --git a/apps/server/package.json b/apps/server/package.json index 69ac42c480946986c7706bdb918e48b45390d81d..e8e6a978eb17293dbb64c8281577a5a527958752 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "dependencies": { + "better-sqlite3": "^12.6.2", + "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" @@ -24,6 +26,7 @@ "devDependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d34065760877755749806158a6343887a578eb98..159dbbc2a88d5576acec002c83c46b23d9de7bc2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -118,6 +118,7 @@ async function main() { port, host: mode === "desktop" ? "127.0.0.1" : undefined, cwd, + stateDir, staticDir, devUrl, projectRegistry, diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..527ff57f6aeedc1e3fcc77406c9ac3213594f89e --- /dev/null +++ b/apps/server/src/orchestration/engine.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { OrchestrationEngine } from "./engine"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("OrchestrationEngine", () => { + it("replays to the same deterministic snapshot", async () => { + const stateDir = makeTempDir("t3code-orchestration-"); + const createdAt = new Date().toISOString(); + const projectId = "project-1"; + const threadId = "thread-1"; + + const engineA = new OrchestrationEngine(stateDir); + await engineA.start(); + await engineA.dispatch({ + type: "project.create", + commandId: "cmd-1", + projectId, + name: "demo", + cwd: "/tmp/demo", + model: "gpt-5-codex", + createdAt, + }); + await engineA.dispatch({ + type: "thread.create", + commandId: "cmd-2", + threadId, + projectId, + title: "Thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }); + await engineA.dispatch({ + type: "message.send", + commandId: "cmd-3", + threadId, + messageId: "msg-1", + role: "user", + text: "hello", + streaming: false, + createdAt, + }); + const snapshotA = engineA.getSnapshot(); + await engineA.stop(); + + const engineB = new OrchestrationEngine(stateDir); + await engineB.start(); + const snapshotB = engineB.getSnapshot(); + expect(snapshotB).toEqual(snapshotA); + await engineB.stop(); + }); + + it("fans out read-model updates to subscribers", async () => { + const stateDir = makeTempDir("t3code-orchestration-fanout-"); + const engine = new OrchestrationEngine(stateDir); + await engine.start(); + const updates: number[] = []; + const unsubscribe = engine.subscribeToReadModel((snapshot) => { + updates.push(snapshot.sequence); + }); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-project", + projectId: "project-2", + name: "fanout", + cwd: "/tmp/fanout", + model: "gpt-5-codex", + createdAt: new Date().toISOString(), + }); + unsubscribe(); + expect(updates.length).toBeGreaterThan(0); + await engine.stop(); + }); + + it("replays append-only events from sequence", async () => { + const stateDir = makeTempDir("t3code-orchestration-replay-"); + const engine = new OrchestrationEngine(stateDir); + await engine.start(); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-a", + projectId: "project-replay", + name: "replay", + cwd: "/tmp/replay", + model: "gpt-5-codex", + createdAt: new Date().toISOString(), + }); + await engine.dispatch({ + type: "project.delete", + commandId: "cmd-b", + projectId: "project-replay", + createdAt: new Date().toISOString(), + }); + const events = await engine.replayEvents(0); + expect(events.length).toBe(2); + expect(events[0]?.type).toBe("project.created"); + expect(events[1]?.type).toBe("project.deleted"); + await engine.stop(); + }); +}); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b93c507a3f944a0f55dad7826b028181d29664d --- /dev/null +++ b/apps/server/src/orchestration/engine.ts @@ -0,0 +1,274 @@ +import path from "node:path"; + +import type { + OrchestrationCommand, + OrchestrationEvent, + OrchestrationReadModel, +} from "@t3tools/contracts"; +import { OrchestrationCommandSchema } from "@t3tools/contracts"; +import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; + +import { createLogger } from "../logger"; +import { SqliteEventStore } from "./eventStore"; +import { createEmptyReadModel, reduceEvent } from "./reducer"; +import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; + +type CommandEnvelope = { + command: OrchestrationCommand; + resolve: (result: { sequence: number }) => void; + reject: (error: Error) => void; +}; + +function mapCommandToEvent(command: OrchestrationCommand): Omit { + const eventId = crypto.randomUUID(); + switch (command.type) { + case "project.create": + return { + eventId, + type: "project.created", + aggregateType: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.projectId, + name: command.name, + cwd: command.cwd, + model: command.model, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + case "project.delete": + return { + eventId, + type: "project.deleted", + aggregateType: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.projectId, + deletedAt: command.createdAt, + }, + }; + case "thread.create": + return { + eventId, + type: "thread.created", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.threadId, + projectId: command.projectId, + title: command.title, + model: command.model, + branch: command.branch, + worktreePath: command.worktreePath, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + case "thread.delete": + return { + eventId, + type: "thread.deleted", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.threadId, + deletedAt: command.createdAt, + }, + }; + case "thread.meta.update": + return { + eventId, + type: "thread.meta-updated", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + threadId: command.threadId, + ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.model !== undefined ? { model: command.model } : {}), + ...(command.branch !== undefined ? { branch: command.branch } : {}), + ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + updatedAt: command.createdAt, + }, + }; + case "message.send": + return { + eventId, + type: "message.sent", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.messageId, + role: command.role, + text: command.text, + threadId: command.threadId, + createdAt: command.createdAt, + streaming: command.streaming === true, + }, + }; + case "thread.session": + return { + eventId, + type: "thread.session-set", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + threadId: command.threadId, + session: command.session, + }, + }; + case "git.readModel.upsert": + return { + eventId, + type: "git.read-model-upsert", + aggregateType: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + projectId: command.projectId, + branch: command.branch, + hasWorkingTreeChanges: command.hasWorkingTreeChanges, + aheadCount: command.aheadCount, + behindCount: command.behindCount, + updatedAt: command.createdAt, + }, + }; + } +} + +export class OrchestrationEngine { + private readonly logger = createLogger("orchestration"); + private readonly runtime = Runtime.defaultRuntime; + private readonly eventStore: SqliteEventStore; + + private readModel: OrchestrationReadModel; + private commandQueue: Queue.Queue; + private readModelPubSub: PubSub.PubSub; + private eventPubSub: PubSub.PubSub; + private workerFiber: Fiber.RuntimeFiber | null = null; + private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); + private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); + + constructor(stateDir: string) { + const dbPath = path.join(stateDir, "orchestration.sqlite"); + this.eventStore = new SqliteEventStore(dbPath); + this.readModel = createEmptyReadModel(new Date().toISOString()); + this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded()); + this.readModelPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); + this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); + } + + async start(): Promise { + const existingEvents = await Runtime.runPromise(this.runtime)(this.eventStore.readAll()); + for (const event of existingEvents) { + this.readModel = reduceEvent(this.readModel, event); + } + + const worker = Stream.fromQueue(this.commandQueue).pipe( + Stream.runForEach((envelope) => + Effect.tryPromise({ + try: async () => { + const eventBase = mapCommandToEvent(envelope.command); + const savedEvent = await Runtime.runPromise(this.runtime)(this.eventStore.append(eventBase)); + this.readModel = reduceEvent(this.readModel, savedEvent); + await Promise.all([ + Runtime.runPromise(this.runtime)(PubSub.publish(this.eventPubSub, savedEvent)), + Runtime.runPromise(this.runtime)(PubSub.publish(this.readModelPubSub, this.readModel)), + ]); + for (const listener of this.domainEventListeners) { + listener(savedEvent); + } + for (const listener of this.readModelListeners) { + listener(this.readModel); + } + envelope.resolve({ sequence: savedEvent.sequence }); + }, + catch: (error) => { + const message = error instanceof Error ? error.message : "Unknown command processing error"; + envelope.reject(new Error(message)); + return undefined; + }, + }), + ), + ); + + this.workerFiber = Runtime.runFork(this.runtime)(worker); + this.logger.info("orchestration engine started", { + sequence: this.readModel.sequence, + contracts: UI_ENTITY_CONTRACTS.length, + }); + } + + async stop(): Promise { + if (this.workerFiber) { + await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber)); + this.workerFiber = null; + } + } + + getSnapshot(): OrchestrationReadModel { + return this.readModel; + } + + async replayEvents(fromSequenceExclusive: number): Promise { + return Runtime.runPromise(this.runtime)(this.eventStore.readFromSequence(fromSequenceExclusive)); + } + + async dispatchUnknown(command: unknown): Promise<{ sequence: number }> { + const decode = Schema.decodeUnknownEither(OrchestrationCommandSchema); + let payload: unknown = command; + if (typeof command === "string") { + try { + payload = JSON.parse(command) as unknown; + } catch { + throw new Error("Invalid orchestration command: payload is not valid JSON"); + } + } + const decoded = decode(payload); + if (Either.isLeft(decoded)) { + const issues = decoded.left.toString(); + throw new Error(`Invalid orchestration command: ${issues}`); + } + return this.dispatch(decoded.right); + } + + async dispatch(command: OrchestrationCommand): Promise<{ sequence: number }> { + return new Promise<{ sequence: number }>((resolve, reject) => { + Runtime.runPromise(this.runtime)( + Queue.offer(this.commandQueue, { command, resolve, reject }), + ).catch((error) => { + const message = error instanceof Error ? error.message : "Queue offer failed"; + reject(new Error(message)); + }); + }); + } + + subscribeToReadModel(callback: (snapshot: OrchestrationReadModel) => void): () => void { + this.readModelListeners.add(callback); + return () => { + this.readModelListeners.delete(callback); + }; + } + + subscribeToDomainEvents(callback: (event: OrchestrationEvent) => void): () => void { + this.domainEventListeners.add(callback); + return () => { + this.domainEventListeners.delete(callback); + }; + } +} diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/orchestration/eventStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc54c9767d68107dde454f52cdc817ca64bf4694 --- /dev/null +++ b/apps/server/src/orchestration/eventStore.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { OrchestrationEvent } from "@t3tools/contracts"; +import Database from "better-sqlite3"; +import { Effect } from "effect"; + +interface EventRow { + sequence: number; + event_id: string; + event_type: string; + aggregate_type: string; + aggregate_id: string; + occurred_at: string; + command_id: string | null; + payload_json: string; +} + +export class SqliteEventStore { + private readonly db: Database.Database; + + constructor(dbPath: string) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.pragma("journal_mode = WAL"); + this.db.pragma("foreign_keys = ON"); + this.migrate(); + } + + private migrate(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + occurred_at TEXT NOT NULL, + command_id TEXT, + payload_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate + ON orchestration_events(aggregate_type, aggregate_id, sequence); + `); + } + + append(event: Omit): Effect.Effect { + return Effect.sync(() => { + const stmt = this.db.prepare(` + INSERT INTO orchestration_events ( + event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + event.eventId, + event.type, + event.aggregateType, + event.aggregateId, + event.occurredAt, + event.commandId, + JSON.stringify(event.payload), + ); + + return { + ...event, + sequence: Number(result.lastInsertRowid), + }; + }); + } + + readFromSequence(sequenceExclusive: number, limit = 1_000): Effect.Effect { + return Effect.sync(() => { + const rows = this.db + .prepare( + ` + SELECT sequence, event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json + FROM orchestration_events + WHERE sequence > ? + ORDER BY sequence ASC + LIMIT ? + `, + ) + .all(sequenceExclusive, limit) as EventRow[]; + return rows.map((row) => ({ + sequence: row.sequence, + eventId: row.event_id, + type: row.event_type, + aggregateType: row.aggregate_type, + aggregateId: row.aggregate_id, + occurredAt: row.occurred_at, + commandId: row.command_id, + payload: JSON.parse(row.payload_json) as unknown, + })); + }); + } + + readAll(): Effect.Effect { + return this.readFromSequence(0, Number.MAX_SAFE_INTEGER); + } +} diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..0aafdc2646bc3c0e36e3890418d95452f7abc940 --- /dev/null +++ b/apps/server/src/orchestration/reducer.ts @@ -0,0 +1,217 @@ +import type { + OrchestrationEvent, + OrchestrationGitReadModel, + OrchestrationMessage, + OrchestrationProject, + OrchestrationReadModel, + OrchestrationSession, + OrchestrationThread, +} from "@t3tools/contracts"; + +type ThreadPatch = Partial>; + +function updateThread( + threads: ReadonlyArray, + threadId: string, + patch: ThreadPatch, +): OrchestrationThread[] { + return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + return value as Record; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { + return { + sequence: 0, + projects: [], + threads: [], + gitByProjectId: {}, + updatedAt: nowIso, + }; +} + +export function reduceEvent( + model: OrchestrationReadModel, + event: OrchestrationEvent, +): OrchestrationReadModel { + const payload = asObject(event.payload); + const nextBase: OrchestrationReadModel = { + ...model, + sequence: event.sequence, + updatedAt: event.occurredAt, + }; + + switch (event.type) { + case "project.created": { + if (!payload) return nextBase; + const project: OrchestrationProject = { + id: asString(payload.id) ?? event.aggregateId, + name: asString(payload.name) ?? "project", + cwd: asString(payload.cwd) ?? "", + model: asString(payload.model) ?? "gpt-5-codex", + createdAt: asString(payload.createdAt) ?? event.occurredAt, + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }; + const existing = nextBase.projects.find((entry) => entry.id === project.id); + return { + ...nextBase, + projects: existing + ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...nextBase.projects, project], + }; + } + case "project.deleted": + return { + ...nextBase, + projects: nextBase.projects.filter((project) => project.id !== event.aggregateId), + threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId), + }; + case "thread.created": { + if (!payload) return nextBase; + const thread: OrchestrationThread = { + id: asString(payload.id) ?? event.aggregateId, + projectId: asString(payload.projectId) ?? "", + title: asString(payload.title) ?? "New thread", + model: asString(payload.model) ?? "gpt-5-codex", + branch: asString(payload.branch), + worktreePath: asString(payload.worktreePath), + createdAt: asString(payload.createdAt) ?? event.occurredAt, + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + latestTurnId: null, + latestTurnStartedAt: null, + latestTurnCompletedAt: null, + latestTurnDurationMs: null, + messages: [], + session: null, + turnDiffSummaries: [], + error: null, + }; + const existing = nextBase.threads.find((entry) => entry.id === thread.id); + return { + ...nextBase, + threads: existing + ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) + : [...nextBase.threads, thread], + }; + } + case "thread.deleted": + return { + ...nextBase, + threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), + }; + case "thread.meta-updated": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const thread = nextBase.threads.find((entry) => entry.id === threadId); + if (!thread) return nextBase; + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}), + ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}), + ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}), + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }), + }; + } + case "message.sent": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const message: OrchestrationMessage = { + id: asString(payload.id) ?? crypto.randomUUID(), + role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user", + text: asString(payload.text) ?? "", + createdAt: asString(payload.createdAt) ?? event.occurredAt, + streaming: payload.streaming === true, + }; + const targetThread = nextBase.threads.find((thread) => thread.id === threadId); + if (!targetThread) return nextBase; + const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); + const nextMessages = existingMessage + ? targetThread.messages.map((entry) => + entry.id === message.id + ? { + ...entry, + text: + message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + createdAt: message.createdAt, + } + : entry, + ) + : [...targetThread.messages, message]; + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + messages: nextMessages, + updatedAt: event.occurredAt, + }), + }; + } + case "thread.session-set": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const thread = nextBase.threads.find((entry) => entry.id === threadId); + if (!thread) return nextBase; + const sessionPayload = asObject(payload.session); + if (!sessionPayload) return nextBase; + const session: OrchestrationSession = { + sessionId: asString(sessionPayload.sessionId) ?? crypto.randomUUID(), + status: + (asString(sessionPayload.status) as OrchestrationSession["status"] | null) ?? "connecting", + provider: + (asString(sessionPayload.provider) as OrchestrationSession["provider"] | null) ?? "codex", + threadId, + activeTurnId: asString(sessionPayload.activeTurnId), + createdAt: asString(sessionPayload.createdAt) ?? event.occurredAt, + updatedAt: asString(sessionPayload.updatedAt) ?? event.occurredAt, + lastError: asString(sessionPayload.lastError), + }; + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + session, + updatedAt: event.occurredAt, + error: session.lastError, + }), + }; + } + case "git.read-model-upsert": { + if (!payload) return nextBase; + const projectId = asString(payload.projectId) ?? event.aggregateId; + const gitReadModel: OrchestrationGitReadModel = { + projectId, + branch: asString(payload.branch), + hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true, + aheadCount: asNumber(payload.aheadCount) ?? 0, + behindCount: asNumber(payload.behindCount) ?? 0, + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }; + return { + ...nextBase, + gitByProjectId: { + ...nextBase.gitByProjectId, + [projectId]: gitReadModel, + }, + }; + } + default: + return nextBase; + } +} diff --git a/apps/server/src/orchestration/uiContractInventory.ts b/apps/server/src/orchestration/uiContractInventory.ts new file mode 100644 index 0000000000000000000000000000000000000000..d138b5871245e14b26ab06e99d202ae6eb156ca9 --- /dev/null +++ b/apps/server/src/orchestration/uiContractInventory.ts @@ -0,0 +1,66 @@ +export interface UiEntityContract { + entity: string; + requiredFields: string[]; + consumedBy: string[]; +} + +// Explicit UI inventory used to keep server read models aligned +// with what the current frontend renders. +export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ + { + entity: "Project", + requiredFields: ["id", "name", "cwd", "model"], + consumedBy: ["apps/web/src/components/Sidebar.tsx"], + }, + { + entity: "Thread", + requiredFields: [ + "id", + "projectId", + "title", + "model", + "branch", + "worktreePath", + "createdAt", + "latestTurnId", + "latestTurnStartedAt", + "latestTurnCompletedAt", + "latestTurnDurationMs", + "error", + ], + consumedBy: [ + "apps/web/src/components/Sidebar.tsx", + "apps/web/src/components/ChatView.tsx", + "apps/web/src/components/BranchToolbar.tsx", + ], + }, + { + entity: "Message", + requiredFields: ["id", "role", "text", "createdAt", "streaming"], + consumedBy: ["apps/web/src/components/ChatView.tsx"], + }, + { + entity: "ProviderSession", + requiredFields: [ + "sessionId", + "provider", + "status", + "threadId", + "activeTurnId", + "createdAt", + "updatedAt", + "lastError", + ], + consumedBy: ["apps/web/src/components/ChatView.tsx", "apps/web/src/components/Sidebar.tsx"], + }, + { + entity: "TurnDiffSummary", + requiredFields: ["turnId", "completedAt", "status", "files"], + consumedBy: ["apps/web/src/components/DiffPanel.tsx", "apps/web/src/components/ChatView.tsx"], + }, + { + entity: "GitReadModel", + requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"], + consumedBy: ["apps/web/src/components/GitActionsControl.tsx", "apps/web/src/components/BranchToolbar.tsx"], + }, +]; diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 48782bd26e404994d6471aaf02fd0d26546e4d19..a9b9cc110241bb7586a1555ba643a22c8d5f0260 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -621,7 +621,8 @@ describe("WebSocket Server", () => { const ws = await connectWs(port); connections.push(ws); - // Consume welcome + // Consume welcome + initial orchestration snapshot pushes + await waitForMessage(ws); await waitForMessage(ws); const response = await sendRequest(ws, "nonexistent.method"); @@ -629,23 +630,6 @@ describe("WebSocket Server", () => { expect(response.error!.message).toContain("Unknown method"); }); - it("responds to providers.listSessions with empty array", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const ws = await connectWs(port); - connections.push(ws); - - // Consume welcome - await waitForMessage(ws); - - const response = await sendRequest(ws, WS_METHODS.providersListSessions); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual([]); - }); - it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { server = createTestServer({ cwd: "/test" }); await server.start(); @@ -800,9 +784,21 @@ describe("WebSocket Server", () => { // Send garbage ws.send("not json at all"); - const response = (await waitForMessage(ws)) as WsResponse; - expect(response.error).toBeDefined(); - expect(response.error!.message).toContain("Invalid request format"); + let response: WsResponse | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + const message = (await waitForMessage(ws)) as Record; + if (typeof message.id === "string" && message.id === "unknown") { + response = message as WsResponse; + break; + } + if (message.error) { + response = message as WsResponse; + break; + } + } + expect(response).toBeDefined(); + expect(response!.error).toBeDefined(); + expect(response!.error!.message).toContain("Invalid request format"); }); it("supports projects list/add/dedupe/remove", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 325727827d2fb6a3e68106b4d0c8e84a8fd1b29e..a4cfef7dad2d1c30d83f63b1b73329912e99af9c 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -7,8 +7,11 @@ import type { Duplex } from "node:stream"; import { EDITORS, + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, WS_CHANNELS, WS_METHODS, + type OrchestrationReadModelPush, type TerminalEvent, type WsPush, type WsRequest, @@ -33,6 +36,7 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; +import { OrchestrationEngine } from "./orchestration/engine"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -53,6 +57,7 @@ export interface ServerOptions { port: number; host?: string | undefined; cwd: string; + stateDir?: string | undefined; staticDir?: string | undefined; devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; @@ -75,6 +80,7 @@ export function createServer(options: ServerOptions) { port, host, cwd, + stateDir, staticDir, devUrl, logWebSocketEvents: explicitLogWsEvents, @@ -88,6 +94,9 @@ export function createServer(options: ServerOptions) { const projectRegistry = providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); const gitManager = providedGitManager ?? new GitManager(); + const orchestrationEngine = new OrchestrationEngine( + stateDir ?? path.join(os.homedir(), ".t3", "userdata"), + ); const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -103,22 +112,113 @@ export function createServer(options: ServerOptions) { }); } + const unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: snapshot.sequence, + snapshot, + } satisfies OrchestrationReadModelPush, + }; + const message = JSON.stringify(push); + let recipients = 0; + for (const client of clients) { + if (client.readyState === client.OPEN) { + client.send(message); + recipients += 1; + } + } + logOutgoingPush(push, recipients); + }); + + const unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, + data: event, + }; + const message = JSON.stringify(push); + let recipients = 0; + for (const client of clients) { + if (client.readyState === client.OPEN) { + client.send(message); + recipients += 1; + } + } + logOutgoingPush(push, recipients); + }); + // Forward provider events to all connected WebSocket clients providerManager.on("event", (event) => { - const push: WsPush = { - type: "push", - channel: WS_CHANNELS.providerEvent, - data: event, - }; - const message = JSON.stringify(push); - let recipients = 0; - for (const client of clients) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; - } - } - logOutgoingPush(push, recipients); + void (async () => { + const snapshot = orchestrationEngine.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); + if (!thread) return; + const now = event.createdAt; + if (event.method === "turn/started" || event.method === "turn/completed") { + await orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: event.method === "turn/started" ? "running" : "ready", + threadId: thread.id, + activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }); + } + if (event.textDelta && event.textDelta.length > 0) { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: event.textDelta, + streaming: true, + createdAt: now, + }); + } + if (event.method === "turn/completed") { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: "", + streaming: false, + createdAt: now, + }); + } + if (event.kind === "error" && event.message) { + await orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: "error", + threadId: thread.id, + activeTurnId: event.turnId ?? null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: event.message, + }, + createdAt: now, + }); + } + })().catch(() => undefined); }); const onTerminalEvent = (event: TerminalEvent) => { @@ -247,6 +347,17 @@ export function createServer(options: ServerOptions) { logOutgoingPush(welcome, 1); ws.send(JSON.stringify(welcome)); + const snapshotPush: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: orchestrationEngine.getSnapshot().sequence, + snapshot: orchestrationEngine.getSnapshot(), + } satisfies OrchestrationReadModelPush, + }; + logOutgoingPush(snapshotPush, 1); + ws.send(JSON.stringify(snapshotPush)); + ws.on("message", (raw) => { void handleMessage(ws, raw); }); @@ -307,9 +418,6 @@ export function createServer(options: ServerOptions) { return undefined; } - case WS_METHODS.providersListSessions: - return providerManager.listSessions(); - case WS_METHODS.providersListCheckpoints: return providerManager.listCheckpoints(request.params as never); @@ -323,11 +431,32 @@ export function createServer(options: ServerOptions) { return projectRegistry.list(); case WS_METHODS.projectsAdd: - return projectRegistry.add(request.params as never); + { + const result = await projectRegistry.add(request.params as never); + await orchestrationEngine.dispatch({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId: result.project.id, + name: result.project.name, + cwd: result.project.cwd, + model: "gpt-5-codex", + createdAt: new Date().toISOString(), + }); + return result; + } case WS_METHODS.projectsRemove: - projectRegistry.remove(request.params as never); - return undefined; + { + const params = request.params as { id: string }; + projectRegistry.remove(request.params as never); + await orchestrationEngine.dispatch({ + type: "project.delete", + commandId: crypto.randomUUID(), + projectId: params.id, + createdAt: new Date().toISOString(), + }); + return undefined; + } case WS_METHODS.projectsSearchEntries: return searchWorkspaceEntries(request.params as never); @@ -379,7 +508,24 @@ export function createServer(options: ServerOptions) { } case WS_METHODS.gitStatus: - return gitManager.status(request.params as never); + { + const params = request.params as { cwd: string }; + const status = await gitManager.status(request.params as never); + const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd); + if (project) { + await orchestrationEngine.dispatch({ + type: "git.readModel.upsert", + commandId: crypto.randomUUID(), + projectId: project.id, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + createdAt: new Date().toISOString(), + }); + } + return status; + } case WS_METHODS.gitPull: return pullGitBranch(request.params as never); @@ -438,6 +584,25 @@ export function createServer(options: ServerOptions) { keybindings: keybindingsConfig, }; + case ORCHESTRATION_WS_METHODS.getSnapshot: + return orchestrationEngine.getSnapshot(); + + case ORCHESTRATION_WS_METHODS.dispatchCommand: + return orchestrationEngine.dispatchUnknown(request.params); + + case ORCHESTRATION_WS_METHODS.replayEvents: + return orchestrationEngine.replayEvents( + Math.max( + 0, + Math.floor( + Number( + (request.params as { fromSequenceExclusive?: number } | undefined) + ?.fromSequenceExclusive ?? 0, + ), + ), + ), + ); + default: throw new Error(`Unknown method: ${request.method}`); } @@ -450,9 +615,14 @@ export function createServer(options: ServerOptions) { reject(error); }; httpServer.once("error", onError); - const onListening = () => { + const onListening = async () => { httpServer.off("error", onError); - resolve(); + try { + await orchestrationEngine.start(); + resolve(); + } catch (error) { + reject(error instanceof Error ? error : new Error("Failed to start orchestration engine")); + } }; if (host) { httpServer.listen(port, host, onListening); @@ -463,6 +633,9 @@ export function createServer(options: ServerOptions) { } async function stop(): Promise { + unsubscribeReadModel(); + unsubscribeDomainEvents(); + await orchestrationEngine.stop(); terminalManager.off("event", onTerminalEvent); providerManager.stopAll(); providerManager.dispose(); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 21113658fcd3d99320b2a19cdbd1f5fa2873f5a8..6fe19dfd95464492a9a272604cae71be6e6219e2 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -92,13 +92,23 @@ export default function BranchToolbar({ }); if (!activeThreadId || !syncedBranch) return; + if (api) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThreadId, + branch: syncedBranch, + worktreePath: null, + createdAt: new Date().toISOString(), + }); + } dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch: syncedBranch, worktreePath: null, }); - }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]); + }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch, api]); useEffect(() => { if (isBranchMenuOpen) return; @@ -120,6 +130,16 @@ export default function BranchToolbar({ if (sessionId && worktreePath !== activeWorktreePath) { void api?.providers.stopSession({ sessionId }).catch(() => undefined); } + if (api) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThreadId, + branch, + worktreePath, + createdAt: new Date().toISOString(), + }); + } dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92a6a7392854bf12d7a1a033bb5dc2b57c3ccc11..0a1423863ea3372ebfefe71df270ab353b6e0c69 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -57,7 +57,6 @@ import { derivePhase, deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, - type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, @@ -72,6 +71,7 @@ import { MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment, type TurnDiffFileChange, + type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; @@ -433,28 +433,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); - const workLogEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), - [activeThread?.events], - ); + const workLogEntries = useMemo(() => deriveWorkLogEntries([], undefined), []); const latestTurnWorkEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), - [activeThread?.events, activeThread?.latestTurnId], + () => deriveWorkLogEntries([], activeThread?.latestTurnId), + [activeThread?.latestTurnId], ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(activeThread?.events ?? []), - [activeThread?.events], - ); - const assistantCompletionByItemId = useMemo(() => { - const map = new Map(); - const ordered = [...(activeThread?.events ?? [])].toReversed(); - for (const event of ordered) { - if (event.method !== "item/completed") continue; - if (!event.itemId) continue; - map.set(event.itemId, event.createdAt); - } - return map; - }, [activeThread?.events]); + const pendingApprovals = useMemo(() => derivePendingApprovals([]), []); + const assistantCompletionByItemId = useMemo(() => new Map(), []); const timelineEntries = useMemo( () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], @@ -565,36 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - useEffect(() => { - if (!activeThreadId || turnDiffSummaries.length === 0) { - return; - } - - const inferredMissingTurnCounts = turnDiffSummaries.reduce>( - (acc, summary) => { - if (typeof summary.checkpointTurnCount === "number") { - return acc; - } - const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof inferredTurnCount !== "number") { - return acc; - } - acc[summary.turnId] = inferredTurnCount; - return acc; - }, - {}, - ); - if (Object.keys(inferredMissingTurnCounts).length === 0) { - return; - } - - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: activeThreadId, - checkpointTurnCountByTurnId: inferredMissingTurnCounts, - }); - }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); - const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; @@ -1473,28 +1428,14 @@ export default function ChatView({ threadId }: ChatViewProps) { checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); try { - const result = await api.providers.listCheckpoints({ + await api.providers.listCheckpoints({ sessionId: input.sessionId, }); - const turnIdSet = new Set(turnIds); - const checkpointTurnCountByTurnId = Object.fromEntries( - result.checkpoints - .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) - .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), - ); - if (Object.keys(checkpointTurnCountByTurnId).length === 0) { - return; - } - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: input.threadId, - checkpointTurnCountByTurnId, - }); } catch { checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); } }, - [api, dispatch, turnDiffSummaries], + [api, turnDiffSummaries], ); const ensureSession = useCallback( @@ -1533,10 +1474,21 @@ export default function ChatView({ threadId }: ChatViewProps) { approvalPolicy: runtimeApprovalPolicy, sandboxMode: runtimeSandboxMode, }); - dispatch({ - type: "UPDATE_SESSION", + await api.orchestration.dispatchCommand({ + type: "thread.session", + commandId: crypto.randomUUID(), threadId: activeThread.id, - session, + session: { + sessionId: session.sessionId, + provider: session.provider, + status: session.status, + threadId: session.threadId ?? activeThread.id, + activeTurnId: session.activeTurnId ?? null, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + lastError: session.lastError ?? null, + }, + createdAt: new Date().toISOString(), }); const resolvedThreadId = session.threadId ?? null; const continuityState: SessionContinuityState = @@ -1572,7 +1524,6 @@ export default function ChatView({ threadId }: ChatViewProps) { api, codexBinaryPath, codexHomePath, - dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel, @@ -1642,27 +1593,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!sessionInfo) { return; } - const result = await api.providers.revertToCheckpoint({ + await api.providers.revertToCheckpoint({ sessionId: sessionInfo.sessionId, turnCount, }); - dispatch({ - type: "REVERT_TO_CHECKPOINT", - threadId: activeThread.id, - sessionId: sessionInfo.sessionId, - threadRuntimeId: result.threadId, - turnCount: result.turnCount, - messageCount: result.messageCount, - }); - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: activeThread.id, - checkpointTurnCountByTurnId: Object.fromEntries( - result.checkpoints - .filter((entry) => entry.turnCount > 0) - .map((entry) => [entry.id, entry.turnCount] as const), - ), - }); } catch (err) { setThreadError( activeThread.id, @@ -1709,12 +1643,16 @@ export default function ChatView({ threadId }: ChatViewProps) { newBranch, }); sessionCwd = result.worktree.path; - dispatch({ - type: "SET_THREAD_BRANCH", - threadId: activeThread.id, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); + if (api) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + branch: result.worktree.branch, + worktreePath: result.worktree.path, + createdAt: new Date().toISOString(), + }); + } const setupScript = setupProjectScript(activeProject.scripts); if (setupScript) { void runProjectScript(setupScript, { @@ -1741,11 +1679,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); const title = truncateTitle(titleSeed); - dispatch({ - type: "SET_THREAD_TITLE", - threadId: activeThread.id, - title, - }); + if (api) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + title, + createdAt: new Date().toISOString(), + }); + } } setThreadError(activeThread.id, null); @@ -1757,13 +1699,19 @@ export default function ChatView({ threadId }: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - dispatch({ - type: "PUSH_USER_MESSAGE", - threadId: activeThread.id, - id: crypto.randomUUID(), - text: trimmed, - ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), - }); + const userMessageId = crypto.randomUUID(); + if (api) { + await api.orchestration.dispatchCommand({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + messageId: userMessageId, + role: "user", + text: trimmed, + streaming: false, + createdAt: new Date().toISOString(), + }); + } const previousMessages = activeThread.messages; promptRef.current = ""; setPrompt(""); @@ -1852,14 +1800,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const onModelSelect = useCallback( (model: ModelSlug) => { if (!activeThread) return; - dispatch({ - type: "SET_THREAD_MODEL", - threadId: activeThread.id, - model: resolveModelSlug(model), - }); + if (api) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + model: resolveModelSlug(model), + createdAt: new Date().toISOString(), + }); + } scheduleComposerFocus(); }, - [activeThread, dispatch, scheduleComposerFocus], + [activeThread, api, scheduleComposerFocus], ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9291843ea9d07e92cbfa44fc8782afe5a55e69dd..0d2ad398f2cddce94d91d416e3bd20027fbac782 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,16 +7,14 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { DEFAULT_MODEL } from "../model-logic"; -import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; -import { type Project, type Thread } from "../types"; +import { type Thread } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { createThread } from "../threadFactory"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -131,13 +129,7 @@ export default function Sidebar() { const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); - const pendingApprovalByThreadId = useMemo(() => { - const map = new Map(); - for (const thread of state.threads) { - map.set(thread.id, derivePendingApprovals(thread.events).length > 0); - } - return map; - }, [state.threads]); + const pendingApprovalByThreadId = useMemo(() => new Map(), []); const handleNewThread = useCallback( ( @@ -147,21 +139,27 @@ export default function Sidebar() { worktreePath?: string | null; }, ) => { - const thread = createThread(projectId, { - model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, + if (!api) return; + const threadId = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; + void api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: crypto.randomUUID(), + threadId, + projectId, + title: "New thread", + model, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - }); - dispatch({ - type: "ADD_THREAD", - thread, + createdAt, }); void navigate({ to: "/$threadId", - params: { threadId: thread.id }, + params: { threadId }, }); }, - [dispatch, navigate, state.projects], + [api, dispatch, navigate, state.projects], ); const focusMostRecentThreadForProject = useCallback( @@ -190,47 +188,30 @@ export default function Sidebar() { setIsAddingProject(true); try { - if (isElectron && api) { - const result = await api.projects.add({ cwd }); - const project: Project = { - id: result.project.id, - name: result.project.name, - cwd: result.project.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: result.project.scripts, - }; - const existingById = state.projects.find((p) => p.id === project.id); - const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); - if (!existingById && !existingByCwd) { - dispatch({ type: "ADD_PROJECT", project }); - } - const resolvedProjectId = existingByCwd?.id ?? project.id; - - if (result.created) { - handleNewThread(resolvedProjectId); - } else { - focusMostRecentThreadForProject(resolvedProjectId); - } - } else { - const existing = state.projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject(existing.id); - return; - } + if (!api) return; + const existing = state.projects.find((project) => project.cwd === cwd); + if (existing) { + focusMostRecentThreadForProject(existing.id); + return; + } - const name = inferProjectName(cwd); - const project: Project = { - id: crypto.randomUUID(), - name, - cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }; - dispatch({ type: "ADD_PROJECT", project }); - handleNewThread(project.id); + const projectId = crypto.randomUUID(); + const now = new Date().toISOString(); + const name = inferProjectName(cwd); + if (isElectron) { + // Keep project registry in sync for desktop integrations. + await api.projects.add({ cwd }); } + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId, + name, + cwd, + model: DEFAULT_MODEL, + createdAt: now, + }); + handleNewThread(projectId); } finally { setIsAddingProject(false); setNewCwd(""); @@ -321,7 +302,12 @@ export default function Sidebar() { const shouldNavigateToFallback = routeThreadId === threadId; const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; - dispatch({ type: "DELETE_THREAD", threadId }); + await api.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: crypto.randomUUID(), + threadId, + createdAt: new Date().toISOString(), + }); if (shouldNavigateToFallback) { if (fallbackThreadId) { void navigate({ @@ -411,7 +397,12 @@ export default function Sidebar() { } } - dispatch({ type: "DELETE_PROJECT", projectId }); + await api.orchestration.dispatchCommand({ + type: "project.delete", + commandId: crypto.randomUUID(), + projectId, + createdAt: new Date().toISOString(), + }); }, [api, dispatch, state.projects, state.threads], ); diff --git a/apps/web/src/hooks/useNativeApi.ts b/apps/web/src/hooks/useNativeApi.ts index 6e24884718d1e0b920db3235d5693f5577655b7c..deb3656e8e92a6288d8de4b0e992215d6315f8bb 100644 --- a/apps/web/src/hooks/useNativeApi.ts +++ b/apps/web/src/hooks/useNativeApi.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { readNativeApi } from "../session-logic"; +import { readNativeApi } from "../nativeApi"; export function useNativeApi() { return useMemo(() => readNativeApi(), []); diff --git a/apps/web/src/hooks/useTurnDiffSummaries.ts b/apps/web/src/hooks/useTurnDiffSummaries.ts index cdab41de82f3e4f2573fa9af5343df3858398087..869603a28de0f9b69395b6687e713033fbad4ca2 100644 --- a/apps/web/src/hooks/useTurnDiffSummaries.ts +++ b/apps/web/src/hooks/useTurnDiffSummaries.ts @@ -1,29 +1,13 @@ -import type { ProviderEvent } from "@t3tools/contracts"; import { useMemo } from "react"; -import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic"; +import { inferCheckpointTurnCountByTurnId } from "../session-logic"; import type { Thread, TurnDiffSummary } from "../types"; -const derivedTurnDiffSummaryCache = new WeakMap(); - -function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] { - const cached = derivedTurnDiffSummaryCache.get(events); - if (cached) { - return cached; - } - const derived = deriveTurnDiffSummaries(events); - derivedTurnDiffSummaryCache.set(events, derived); - return derived; -} - export function useTurnDiffSummaries(activeThread: Thread | undefined) { const turnDiffSummaries = useMemo(() => { if (!activeThread) { return []; } - if (activeThread.turnDiffSummaries.length > 0) { - return activeThread.turnDiffSummaries; - } - return deriveTurnDiffSummariesCached(activeThread.events); + return activeThread.turnDiffSummaries; }, [activeThread]); const inferredCheckpointTurnCountByTurnId = useMemo( diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c01860e0a66bfc051abd33d2062f9d28ac2214d --- /dev/null +++ b/apps/web/src/nativeApi.ts @@ -0,0 +1,18 @@ +import type { NativeApi } from "@t3tools/contracts"; + +import { createWsNativeApi } from "./wsNativeApi"; + +let cachedApi: NativeApi | undefined; + +export function readNativeApi(): NativeApi | undefined { + if (typeof window === "undefined") return undefined; + if (cachedApi) return cachedApi; + + if (window.nativeApi) { + cachedApi = window.nativeApi; + return cachedApi; + } + + cachedApi = createWsNativeApi(); + return cachedApi; +} diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts deleted file mode 100644 index 448806d8e2638a45404eaa4c7f742b542335825d..0000000000000000000000000000000000000000 --- a/apps/web/src/persistenceSchema.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_MODEL } from "./model-logic"; -import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; -import type { Thread } from "./types"; - -describe("hydratePersistedState", () => { - it("returns null for invalid payloads", () => { - expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); - expect(hydratePersistedState("not-json", false)).toBeNull(); - }); - - it("migrates the legacy default model to the current default", () => { - const payload = JSON.stringify({ - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.2-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.2-codex", - messages: [ - { - id: "m-1", - role: "assistant", - text: "Hello", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: true, - }, - ], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, true); - expect(hydrated).not.toBeNull(); - expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); - expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); - expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); - expect(hydrated?.threads[0]?.terminalOpen).toBe(false); - expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); - expect(hydrated?.runtimeMode).toBe("full-access"); - }); - - it("filters unknown project references and repairs active thread", () => { - const payload = JSON.stringify({ - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - projectId: "p-1", - title: "Valid thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - { - id: "t-2", - projectId: "p-missing", - title: "Dangling thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-2", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated).not.toBeNull(); - expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); - expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); - expect(hydrated?.threads[0]?.terminalOpen).toBe(false); - expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - expect(hydrated?.runtimeMode).toBe("full-access"); - }); - - it("hydrates runtime mode from v3 payload", () => { - const payload = JSON.stringify({ - version: 3, - runtimeMode: "approval-required", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - ], - threads: [], - activeThreadId: null, - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.runtimeMode).toBe("approval-required"); - }); - - it("hydrates terminal fields from legacy v6 payload", () => { - const payload = JSON.stringify({ - version: 6, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: null, - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 360, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalLayout: "tabs", - splitTerminalIds: [], - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - lastVisitedAt: "2026-02-08T10:01:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.terminalOpen).toBe(true); - expect(hydrated?.threads[0]?.terminalHeight).toBe(360); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); - expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); - }); - - it("hydrates legacy split layout into a grouped split", () => { - const payload = JSON.stringify({ - version: 6, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: null, - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 360, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - activeTerminalId: "term-2", - terminalLayout: "split", - splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("defaults terminalHeight when hydrating v5 payloads", () => { - const payload = JSON.stringify({ - version: 5, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: null, - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("hydrates persisted turn diff summaries as metadata-only entries", () => { - const payload = JSON.stringify({ - version: 7, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ], - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ]); - }); - - it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => { - const payload = JSON.stringify({ - version: 7, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - files: [{ path: 123 }], - }, - ], - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated).not.toBeNull(); - expect(hydrated?.threads[0]?.id).toBe("t-1"); - expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]); - }); -}); - -describe("toPersistedState", () => { - it("writes v7 payload and strips non-persisted thread fields", () => { - const thread: Thread = { - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 320, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - runningTerminalIds: [], - activeTerminalId: "term-2", - terminalGroups: [ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ], - activeTerminalGroupId: "group-term-2", - session: null, - messages: [ - { - id: "m-1", - role: "user", - text: "Hi", - attachments: [ - { - type: "image", - id: "img-1", - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4_096, - previewUrl: "blob:preview-1", - }, - ], - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - ], - events: [], - error: "boom", - createdAt: "2026-02-08T10:00:00.000Z", - lastVisitedAt: "2026-02-08T10:02:00.000Z", - branch: null, - worktreePath: null, - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - status: "completed", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ], - }; - - const persisted = toPersistedState({ - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [thread], - runtimeMode: "full-access", - }); - - expect(persisted.version).toBe(7); - expect(persisted.runtimeMode).toBe("full-access"); - expect(persisted.threads[0]).toEqual({ - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 320, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalGroups: [ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ], - activeTerminalGroupId: "group-term-2", - messages: [ - { - id: "m-1", - role: "user", - text: "Hi", - attachments: [ - { - type: "image", - id: "img-1", - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4_096, - }, - ], - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - ], - createdAt: thread.createdAt, - lastVisitedAt: thread.lastVisitedAt, - branch: null, - worktreePath: null, - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - status: "completed", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ], - }); - const persistedThread = persisted.threads[0]; - expect(persistedThread).toBeDefined(); - if (!persistedThread) return; - - expect("error" in persistedThread).toBe(false); - expect("session" in persistedThread).toBe(false); - }); -}); diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts deleted file mode 100644 index e1711ef6941462829a55e48a72ba6ecc049ccaa6..0000000000000000000000000000000000000000 --- a/apps/web/src/persistenceSchema.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { z } from "zod"; -import { projectScriptSchema } from "@t3tools/contracts"; - -import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - DEFAULT_RUNTIME_MODE, - type Project, - type RuntimeMode, - type Thread, - type ThreadTerminalGroup, -} from "./types"; - -const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; - -const persistedProjectSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - cwd: z.string().min(1), - model: z.string().min(1), - expanded: z.boolean(), - scripts: z.array(projectScriptSchema).default([]), -}); - -const persistedMessageSchema = z.object({ - id: z.string().min(1), - role: z.enum(["user", "assistant"]), - text: z.string(), - attachments: z - .array( - z.object({ - type: z.literal("image"), - id: z.string().min(1), - name: z.string().min(1), - mimeType: z.string().min(1), - sizeBytes: z.number().int().min(1), - }), - ) - .optional(), - createdAt: z.string().min(1), - streaming: z.boolean(), -}); - -const persistedTerminalGroupSchema = z.object({ - id: z.string().trim().min(1), - terminalIds: z.array(z.string().trim().min(1)), -}); - -const persistedTurnDiffFileChangeSchema = z.object({ - path: z.string().min(1), - kind: z.string().min(1).optional(), - additions: z.number().int().min(0).optional(), - deletions: z.number().int().min(0).optional(), -}); - -const persistedTurnDiffSummarySchema = z.object({ - turnId: z.string().min(1), - completedAt: z.string().min(1), - status: z.string().min(1).optional(), - files: z.array(persistedTurnDiffFileChangeSchema), - assistantMessageId: z.string().min(1).optional(), - checkpointTurnCount: z.number().int().min(0).optional(), -}); - -const persistedThreadSchema = z.object({ - id: z.string().min(1), - codexThreadId: z.string().min(1).nullable().default(null), - projectId: z.string().min(1), - title: z.string().min(1), - model: z.string().min(1), - terminalOpen: z.boolean().default(false), - terminalHeight: z.number().int().min(120).max(4_096).default(DEFAULT_THREAD_TERMINAL_HEIGHT), - terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), - activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), - terminalGroups: z.array(persistedTerminalGroupSchema).default([]), - activeTerminalGroupId: z.string().trim().min(1).optional(), - // Legacy v6 and older fields retained for migration. - terminalLayout: z.enum(["single", "split", "tabs"]).optional(), - splitTerminalIds: z.array(z.string().trim().min(1)).optional(), - messages: z.array(persistedMessageSchema), - createdAt: z.string().min(1), - lastVisitedAt: z.string().min(1).optional(), - branch: z.string().min(1).nullable().optional(), - worktreePath: z.string().min(1).nullable().optional(), - turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]), -}); - -const persistedStateBodySchema = z.object({ - projects: z.array(persistedProjectSchema), - threads: z.array(persistedThreadSchema), - activeThreadId: z.string().min(1).nullable().optional(), -}); - -const runtimeModeSchema = z.enum(["approval-required", "full-access"]); - -export const persistedStateV1Schema = persistedStateBodySchema.extend({ - version: z.literal(1).optional(), -}); - -export const persistedStateV2Schema = persistedStateBodySchema.extend({ - version: z.literal(2).optional(), -}); - -export const persistedStateV3Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(3).optional(), -}); - -export const persistedStateV4Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(4).optional(), -}); - -export const persistedStateV6Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(6).optional(), -}); - -export const persistedStateV7Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(7).optional(), -}); - -export const persistedStateV5Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(5).optional(), -}); - -const persistedStateSchema = z.union([ - persistedStateV7Schema, - persistedStateV6Schema, - persistedStateV5Schema, - persistedStateV4Schema, - persistedStateV3Schema, - persistedStateV2Schema, - persistedStateV1Schema, -]); - -export interface PersistedStoreSnapshot { - projects: Project[]; - threads: Thread[]; - runtimeMode: RuntimeMode; -} - -function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string { - if (!isLegacyPayload) { - return model; - } - - return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; -} - -function hydrateProject( - project: z.infer, - isLegacyPayload: boolean, -): Project { - return { - ...project, - model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), - scripts: project.scripts, - }; -} - -function hydrateThread( - thread: z.infer, - isLegacyPayload: boolean, -): Thread { - const terminalIds = [ - ...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), - ]; - const safeTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; - const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) - ? thread.activeTerminalId - : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const safeTerminalIdSet = new Set(safeTerminalIds); - const assignedTerminalIds = new Set(); - const usedGroupIds = new Set(); - const normalizedGroups: ThreadTerminalGroup[] = []; - const assignUniqueGroupId = (groupId: string): string => { - if (!usedGroupIds.has(groupId)) { - usedGroupIds.add(groupId); - return groupId; - } - let suffix = 2; - while (usedGroupIds.has(`${groupId}-${suffix}`)) { - suffix += 1; - } - const uniqueGroupId = `${groupId}-${suffix}`; - usedGroupIds.add(uniqueGroupId); - return uniqueGroupId; - }; - - for (const terminalGroup of thread.terminalGroups) { - const nextTerminalIds = [ - ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), - ].filter((terminalId) => { - if (!safeTerminalIdSet.has(terminalId)) return false; - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }); - if (nextTerminalIds.length === 0) continue; - for (const terminalId of nextTerminalIds) { - assignedTerminalIds.add(terminalId); - } - const baseGroupId = - terminalGroup.id.trim().length > 0 - ? terminalGroup.id.trim() - : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; - normalizedGroups.push({ - id: assignUniqueGroupId(baseGroupId), - terminalIds: nextTerminalIds, - }); - } - - if (normalizedGroups.length === 0 && thread.terminalLayout === "split") { - const splitTerminalIds = [ - ...new Set( - (thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0), - ), - ].filter((terminalId) => safeTerminalIdSet.has(terminalId)); - if (splitTerminalIds.length >= 2) { - const splitGroupTerminalIds = splitTerminalIds.slice(0, 2); - for (const terminalId of splitGroupTerminalIds) { - assignedTerminalIds.add(terminalId); - } - normalizedGroups.push({ - id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`), - terminalIds: splitGroupTerminalIds, - }); - } - } - - for (const terminalId of safeTerminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - normalizedGroups.push({ - id: assignUniqueGroupId(`group-${terminalId}`), - terminalIds: [terminalId], - }); - } - - const activeGroupIndexFromId = normalizedGroups.findIndex( - (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId, - ); - const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) => - terminalGroup.terminalIds.includes(activeTerminalId), - ); - const activeGroupIndex = - activeGroupIndexFromId >= 0 - ? activeGroupIndexFromId - : activeGroupIndexFromTerminal >= 0 - ? activeGroupIndexFromTerminal - : 0; - const activeTerminalGroupId = - normalizedGroups[activeGroupIndex]?.id ?? - normalizedGroups[0]?.id ?? - `group-${DEFAULT_THREAD_TERMINAL_ID}`; - - return { - id: thread.id, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), - terminalOpen: thread.terminalOpen ?? false, - terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: safeTerminalIds, - runningTerminalIds: [], - activeTerminalId, - terminalGroups: normalizedGroups, - activeTerminalGroupId, - session: null, - messages: thread.messages.map((message) => { - const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); - return { - id: message.id, - role: message.role, - text: message.text, - ...(hydratedAttachments && hydratedAttachments.length > 0 - ? { attachments: hydratedAttachments } - : {}), - createdAt: message.createdAt, - streaming: false, - }; - }), - events: [], - error: null, - createdAt: thread.createdAt, - lastVisitedAt: thread.lastVisitedAt, - branch: thread.branch ?? null, - worktreePath: thread.worktreePath ?? null, - turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ - turnId: summary.turnId, - completedAt: summary.completedAt, - ...(summary.status ? { status: summary.status } : {}), - files: summary.files.map((file) => ({ - path: file.path, - ...(file.kind ? { kind: file.kind } : {}), - ...(typeof file.additions === "number" ? { additions: file.additions } : {}), - ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), - })), - ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), - ...(typeof summary.checkpointTurnCount === "number" - ? { checkpointTurnCount: summary.checkpointTurnCount } - : {}), - })), - }; -} - -export function hydratePersistedState( - raw: string, - isLegacyPayload: boolean, -): PersistedStoreSnapshot | null { - let parsedJson: unknown; - try { - parsedJson = JSON.parse(raw); - } catch { - return null; - } - - const parsedState = persistedStateSchema.safeParse(parsedJson); - if (!parsedState.success) { - return null; - } - - const projects = parsedState.data.projects.map((project) => - hydrateProject(project, isLegacyPayload), - ); - const projectIds = new Set(projects.map((project) => project.id)); - const threads = parsedState.data.threads - .map((thread) => hydrateThread(thread, isLegacyPayload)) - .filter((thread) => projectIds.has(thread.projectId)); - return { - projects, - threads, - runtimeMode: - "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, - }; -} - -export function toPersistedState( - state: PersistedStoreSnapshot, -): z.infer { - return { - version: 7, - projects: state.projects, - threads: state.threads.map((thread) => ({ - id: thread.id, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - model: thread.model, - terminalOpen: thread.terminalOpen, - terminalHeight: thread.terminalHeight, - terminalIds: thread.terminalIds, - activeTerminalId: thread.activeTerminalId, - terminalGroups: thread.terminalGroups, - activeTerminalGroupId: thread.activeTerminalGroupId, - messages: thread.messages.map((message) => ({ - id: message.id, - role: message.role, - text: message.text, - ...(message.attachments && message.attachments.length > 0 - ? { - attachments: message.attachments.map((attachment) => ({ - type: attachment.type, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - })), - } - : {}), - createdAt: message.createdAt, - streaming: message.streaming, - })), - createdAt: thread.createdAt, - ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ - turnId: summary.turnId, - completedAt: summary.completedAt, - ...(summary.status ? { status: summary.status } : {}), - files: summary.files.map((file) => ({ - path: file.path, - ...(file.kind ? { kind: file.kind } : {}), - ...(typeof file.additions === "number" ? { additions: file.additions } : {}), - ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), - })), - ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), - ...(typeof summary.checkpointTurnCount === "number" - ? { checkpointTurnCount: summary.checkpointTurnCount } - : {}), - })), - })), - runtimeMode: state.runtimeMode, - }; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1e7bd0d84d949ba15c212fc0fff6424fc0192669..1ef0eabe4bcbf6440ab2bed923c068ca20d40c42 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -2,17 +2,15 @@ import { Outlet, createRootRouteWithContext, type ErrorComponentProps, - useParams, } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; -import { invalidateGitQueries } from "../lib/gitReactQuery"; import { DEFAULT_MODEL } from "../model-logic"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; @@ -125,67 +123,33 @@ function errorDetails(error: unknown): string { function EventRouter() { const api = useNativeApi(); const { dispatch } = useStore(); - const queryClient = useQueryClient(); - const activeAssistantItemRef = useRef(null); - const activeThreadId = useParams({ - strict: false, - select: (params) => params.threadId, - }); useEffect(() => { if (!api) return; - return api.providers.onEvent((event) => { - if (event.method === "turn/completed") { - void invalidateGitQueries(queryClient); - } - if (event.method === "checkpoint/captured") { - const payload = event.payload as { turnCount?: number } | undefined; - const turnCount = payload?.turnCount; - void queryClient.invalidateQueries({ - queryKey: ["providers", "checkpointDiff"] as const, - predicate: (query) => { - if (typeof turnCount !== "number") return true; - return query.queryKey[5] === turnCount; - }, - }); - } - if (!activeThreadId) return; - dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, - activeThreadId, - }); - }); - }, [activeThreadId, api, dispatch, queryClient]); - - useEffect(() => { - if (!activeThreadId) return; - dispatch({ - type: "MARK_THREAD_VISITED", - threadId: activeThreadId, - visitedAt: new Date().toISOString(), - }); - }, [activeThreadId, dispatch]); - - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - dispatch({ - type: "APPLY_TERMINAL_EVENT", - event, - }); + void api.orchestration + .getSnapshot() + .then((snapshot) => { + dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); + }) + .catch(() => undefined); + const unsubReadModel = api.orchestration.onReadModel((snapshot) => { + dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); }); + return () => { + unsubReadModel(); + }; }, [api, dispatch]); return null; } function AutoProjectBootstrap() { + const api = useNativeApi(); const { state, dispatch } = useStore(); const bootstrappedRef = useRef(false); useEffect(() => { + if (!api) return; // Browser mode bootstraps from server welcome. // Electron bootstraps from persisted projects via DesktopProjectBootstrap. if (isElectron) return; @@ -202,75 +166,40 @@ function AutoProjectBootstrap() { } bootstrappedRef.current = true; - - // Create project + thread from server cwd const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, + const threadId = crypto.randomUUID(); + const now = new Date().toISOString(); + void api.orchestration + .dispatchCommand({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId, name: payload.projectName, cwd: payload.cwd, model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }, - }); - dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); - }); - }, [state.projects, dispatch]); - - return null; -} - -function DesktopProjectBootstrap() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - if (!isElectron || !api || bootstrappedRef.current) return; - - let disposed = false; - let retryDelayMs = 500; - let retryTimer: ReturnType | null = null; - - const attemptBootstrap = async () => { - try { - const projects = await api.projects.list(); - if (disposed) return; - dispatch({ - type: "SYNC_PROJECTS", - projects: projects.map((project) => ({ - id: project.id, - name: project.name, - cwd: project.cwd, + createdAt: now, + }) + .then(() => + api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: crypto.randomUUID(), + threadId, + projectId, + title: "New thread", model: DEFAULT_MODEL, - expanded: true, - scripts: project.scripts, - })), - }); - dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); - bootstrappedRef.current = true; - } catch { - if (disposed) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void attemptBootstrap(); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - } - }; + branch: null, + worktreePath: null, + createdAt: now, + }), + ) + .catch(() => undefined); + }); + }, [api, state.projects, dispatch]); - void attemptBootstrap(); - - return () => { - disposed = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [api, dispatch]); + return null; +} +function DesktopProjectBootstrap() { + // Desktop now hydrates from orchestration read-model snapshots. return null; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts deleted file mode 100644 index 7dee1239cabedea48088665b3a81ca912a32e560..0000000000000000000000000000000000000000 --- a/apps/web/src/session-logic.test.ts +++ /dev/null @@ -1,935 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; -import { - type WorkLogEntry, - applyEventToMessages, - derivePendingApprovals, - deriveTurnDiffFilesFromUnifiedDiff, - deriveTurnDiffSummaries, - deriveTimelineEntries, - deriveWorkLogEntries, - evolveSession, -} from "./session-logic"; -import type { ChatMessage } from "./types"; - -function makeEvent(overrides: Partial): ProviderEvent { - return { - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: "2026-02-08T10:00:00.000Z", - method: "noop", - ...overrides, - }; -} - -function makeSession(overrides: Partial = {}): ProviderSession { - return { - sessionId: "sess-1", - provider: "codex", - status: "ready", - createdAt: "2026-02-08T09:59:00.000Z", - updatedAt: "2026-02-08T09:59:00.000Z", - ...overrides, - }; -} - -describe("deriveTimelineEntries", () => { - it("interleaves messages and work entries by timestamp", () => { - const messages: ChatMessage[] = [ - { - id: "m-user", - role: "user", - text: "Hi", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - { - id: "m-assistant", - role: "assistant", - text: "Hello", - createdAt: "2026-02-08T10:05:00.000Z", - streaming: false, - }, - ]; - const workEntries: WorkLogEntry[] = [ - { - id: "w-1", - label: "Tool call", - createdAt: "2026-02-08T10:02:00.000Z", - tone: "tool", - }, - { - id: "w-2", - label: "Preamble", - createdAt: "2026-02-08T10:03:00.000Z", - tone: "thinking", - }, - ]; - - const timeline = deriveTimelineEntries(messages, workEntries); - - expect(timeline.map((entry) => entry.id)).toEqual([ - "message:m-user", - "work:w-1", - "work:w-2", - "message:m-assistant", - ]); - }); - - it("prefers work entries when timestamps are equal", () => { - const messages: ChatMessage[] = [ - { - id: "m-1", - role: "assistant", - text: "Done", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - ]; - const workEntries: WorkLogEntry[] = [ - { - id: "w-1", - label: "Tool call", - createdAt: "2026-02-08T10:00:00.000Z", - tone: "tool", - }, - ]; - - const timeline = deriveTimelineEntries(messages, workEntries); - - expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]); - }); -}); - -describe("deriveWorkLogEntries", () => { - it("drops preamble/work events from the visible work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-1", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { item: { type: "preamble", text: "thinking" } }, - }), - makeEvent({ - id: "evt-2", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { item: { type: "work", text: "planning" } }, - }), - makeEvent({ - id: "evt-3", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { item: { type: "tool_call", command: "ls -la" } }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Tool call"); - }); - - it("does not surface successful turn completion as a work-log row", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:10.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(0); - }); - - it("shows failed turn completion in the work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:10.000Z", - payload: { - turn: { - id: "turn-1", - status: "failed", - error: { message: "sandbox denied" }, - }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Turn failed"); - expect(entries[0]?.detail).toBe("sandbox denied"); - }); - - it("hides reasoning and agent-message noise from the visible work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-summary-part", - method: "item/reasoning/summaryPartAdded", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - }), - makeEvent({ - id: "evt-summary-delta", - method: "item/reasoning/summaryTextDelta", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - }), - makeEvent({ - id: "evt-agent-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - item: { id: "item-msg", type: "agentMessage", text: "Working..." }, - }, - }), - makeEvent({ - id: "evt-agent-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:04.000Z", - payload: { - item: { - id: "item-msg", - type: "agentMessage", - text: "Done response", - }, - }, - }), - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: "ls -la" }, - }, - }), - makeEvent({ - id: "evt-tool-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:06.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: "ls -la" }, - }, - }), - makeEvent({ - id: "evt-turn-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:08.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ], - "turn-1", - ); - - expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); - }); - - it("coalesces command start/completed lifecycle into one entry", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-command-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - item: { - id: "item-command", - type: "command_execution", - command: "git status --short", - }, - }, - }), - makeEvent({ - id: "evt-command-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-command", - type: "command_execution", - command: "git status --short", - }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Command run"); - expect(entries[0]?.detail).toBe("git status --short"); - }); - - it("preserves full tool-call detail text without data truncation", () => { - const longCommand = - '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"'; - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: longCommand }, - }, - }), - ], - "turn-1", - ); - - expect(entries[0]?.detail).toBe(longCommand); - }); - - it("hides generic tool-call rows that have no identifying detail", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call" }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(0); - }); - - it("includes collab tool-call rows with tool name detail", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-collab-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { - id: "item-collab", - type: "collabAgentToolCall", - tool: "spawnAgent", - }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Tool call"); - expect(entries[0]?.detail).toBe("spawnAgent"); - }); - - it("can derive work-log entries across all turns when not scoped", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-turn-1-tool", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { item: { type: "tool_call", command: "ls -la" } }, - }), - makeEvent({ - id: "evt-turn-2-tool", - method: "item/started", - turnId: "turn-2", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { item: { type: "tool_call", command: "pwd" } }, - }), - ], - undefined, - ); - - expect(entries).toHaveLength(2); - expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); - }); -}); - -describe("deriveTurnDiffSummaries", () => { - it("tracks completed turns and links them to assistant message completion", () => { - const summaries = deriveTurnDiffSummaries([ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-file-change", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - item: { - id: "item-file-change", - type: "fileChange", - status: "completed", - changes: [ - { - path: "src/a.ts", - kind: "modified", - diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n", - }, - ], - }, - }, - }), - makeEvent({ - id: "evt-turn-diff-updated", - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - threadId: "thr-1", - turnId: "turn-1", - diff: [ - "diff --git a/src/a.ts b/src/a.ts", - "@@ -1 +1 @@", - "-old", - "+new", - "diff --git a/src/b.ts b/src/b.ts", - "@@ -0,0 +1 @@", - "+created", - ].join("\n"), - }, - }), - makeEvent({ - id: "evt-agent-msg", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - item: { - id: "msg-1", - type: "agentMessage", - text: "Done", - }, - }, - }), - makeEvent({ - id: "evt-turn-completed", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:04.000Z", - payload: { - turn: { - id: "turn-1", - status: "completed", - }, - }, - }), - makeEvent({ - id: "evt-turn-2-file-change", - method: "item/completed", - turnId: "turn-2", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { - id: "item-file-change-2", - type: "fileChange", - status: "completed", - changes: [ - { - path: "src/unfinished.ts", - kind: "modified", - diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts", - }, - ], - }, - }, - }), - ]); - - expect(summaries).toHaveLength(1); - expect(summaries[0]?.turnId).toBe("turn-1"); - expect(summaries[0]?.assistantMessageId).toBe("msg-1"); - expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); - expect(summaries[0]?.files[0]?.kind).toBe("modified"); - expect(summaries[0]?.files[0]?.additions).toBe(1); - expect(summaries[0]?.files[0]?.deletions).toBe(1); - expect(summaries[0]).not.toHaveProperty("unifiedDiff"); - }); - - it("includes completed turns even when no fileChange tool events were emitted", () => { - const summaries = deriveTurnDiffSummaries([ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-turn-completed", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - turn: { - id: "turn-1", - status: "completed", - }, - }, - }), - ]); - - expect(summaries).toHaveLength(1); - expect(summaries[0]?.turnId).toBe("turn-1"); - expect(summaries[0]?.files).toEqual([]); - }); - - it("keeps newest diff data when older events provide stale values", () => { - const summaries = deriveTurnDiffSummaries([ - makeEvent({ - id: "evt-turn-diff-old", - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - diff: [ - "diff --git a/src/example.ts b/src/example.ts", - "@@ -1 +1,2 @@", - "-old", - "+older", - "+extra", - ].join("\n"), - }, - }), - makeEvent({ - id: "evt-turn-diff-new", - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - diff: [ - "diff --git a/src/example.ts b/src/example.ts", - "@@ -1 +1 @@", - "-old", - "+newest", - ].join("\n"), - }, - }), - makeEvent({ - id: "evt-turn-completed", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - turn: { - id: "turn-1", - status: "completed", - }, - }, - }), - ]); - - expect(summaries).toHaveLength(1); - expect(summaries[0]?.files[0]?.path).toBe("src/example.ts"); - expect(summaries[0]?.files[0]?.additions).toBe(1); - expect(summaries[0]?.files[0]?.deletions).toBe(1); - }); -}); - -describe("deriveTurnDiffFilesFromUnifiedDiff", () => { - it("splits a multi-file patch into sorted per-file entries", () => { - const files = deriveTurnDiffFilesFromUnifiedDiff( - [ - "diff --git a/src/b.ts b/src/b.ts", - "@@ -1 +1 @@", - "-old-b", - "+new-b", - "diff --git a/src/a.ts b/src/a.ts", - "@@ -1 +1 @@", - "-old-a", - "+new-a", - ].join("\n"), - ); - - expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); - expect(files[0]?.additions).toBe(1); - expect(files[0]?.deletions).toBe(1); - }); - - it("parses git headers when file paths include ` b/`", () => { - const files = deriveTurnDiffFilesFromUnifiedDiff( - [ - "diff --git a/subdir b/example.ts b/subdir b/example.ts", - "--- a/subdir b/example.ts", - "+++ b/subdir b/example.ts", - "@@ -1 +1 @@", - "-old", - "+new", - ].join("\n"), - ); - - expect(files).toHaveLength(1); - expect(files[0]?.path).toBe("subdir b/example.ts"); - }); - - it("parses git header fallback for binary diffs with ` b/` in path", () => { - const files = deriveTurnDiffFilesFromUnifiedDiff( - [ - "diff --git a/subdir b/image.png b/subdir b/image.png", - "Binary files a/subdir b/image.png and b/subdir b/image.png differ", - ].join("\n"), - ); - - expect(files).toHaveLength(1); - expect(files[0]?.path).toBe("subdir b/image.png"); - }); -}); - -describe("derivePendingApprovals", () => { - it("returns pending command/file-change approvals", () => { - const approvals = derivePendingApprovals([ - makeEvent({ - id: "evt-request", - kind: "request", - method: "item/commandExecution/requestApproval", - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { command: "git commit -m 'msg'" }, - }), - ]); - - expect(approvals).toEqual([ - { - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-08T10:00:00.000Z", - detail: "git commit -m 'msg'", - }, - ]); - }); - - it("removes approvals after a decision event", () => { - const approvals = derivePendingApprovals([ - makeEvent({ - id: "evt-decision", - method: "item/requestApproval/decision", - requestId: "req-1", - createdAt: "2026-02-08T10:00:01.000Z", - }), - makeEvent({ - id: "evt-request", - kind: "request", - method: "item/fileChange/requestApproval", - requestId: "req-1", - requestKind: "file-change", - createdAt: "2026-02-08T10:00:00.000Z", - }), - ]); - - expect(approvals).toHaveLength(0); - }); - - it("clears pending approvals after turn completion", () => { - const approvals = derivePendingApprovals([ - makeEvent({ - id: "evt-turn-complete", - method: "turn/completed", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - makeEvent({ - id: "evt-request", - kind: "request", - method: "item/commandExecution/requestApproval", - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-08T10:00:00.000Z", - }), - ]); - - expect(approvals).toHaveLength(0); - }); -}); - -describe("evolveSession", () => { - it("updates thread id when thread starts", () => { - const previous = makeSession(); - const next = evolveSession( - previous, - makeEvent({ - method: "thread/started", - createdAt: "2026-02-08T10:01:00.000Z", - payload: { thread: { id: "thread-1" } }, - }), - ); - - expect(next.threadId).toBe("thread-1"); - expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); - }); - - it("moves to running and records active turn on turn start", () => { - const previous = makeSession(); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:02:00.000Z", - }), - ); - - expect(next.status).toBe("running"); - expect(next.activeTurnId).toBe("turn-1"); - expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); - }); - - it("returns to ready and clears active turn on successful completion", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - lastError: "older error", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/completed", - createdAt: "2026-02-08T10:03:00.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ); - - expect(next.status).toBe("ready"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("older error"); - }); - - it("marks session as error when turn fails", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/completed", - createdAt: "2026-02-08T10:03:00.000Z", - payload: { - turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, - }, - }), - ); - - expect(next.status).toBe("error"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("boom"); - }); - - it("moves to error on runtime error events", () => { - const previous = makeSession(); - const next = evolveSession( - previous, - makeEvent({ - kind: "error", - method: "runtime/error", - createdAt: "2026-02-08T10:04:00.000Z", - message: "runtime failure", - }), - ); - - expect(next.status).toBe("error"); - expect(next.lastError).toBe("runtime failure"); - }); - - it("closes session on close lifecycle events", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "session/closed", - createdAt: "2026-02-08T10:05:00.000Z", - message: "closed", - }), - ); - - expect(next.status).toBe("closed"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("closed"); - }); -}); - -describe("applyEventToMessages", () => { - it("handles start/delta/completed flow for assistant messages", () => { - const activeAssistantItemRef = { current: null as string | null }; - const started = applyEventToMessages( - [], - makeEvent({ - method: "item/started", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { - item: { - id: "item-1", - type: "agentMessage", - text: "Hello", - }, - }, - }), - activeAssistantItemRef, - ); - - expect(started).toEqual([ - { - id: "item-1", - role: "assistant", - text: "Hello", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: true, - }, - ]); - expect(activeAssistantItemRef.current).toBe("item-1"); - - const withDelta = applyEventToMessages( - started, - makeEvent({ - method: "item/agentMessage/delta", - createdAt: "2026-02-08T10:00:01.000Z", - itemId: "item-1", - textDelta: " world", - }), - activeAssistantItemRef, - ); - expect(withDelta[0]?.text).toBe("Hello world"); - expect(withDelta[0]?.streaming).toBe(true); - - const completed = applyEventToMessages( - withDelta, - makeEvent({ - method: "item/completed", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-1", - type: "agentMessage", - text: "Hello world!", - }, - }, - }), - activeAssistantItemRef, - ); - expect(completed[0]?.text).toBe("Hello world!"); - expect(completed[0]?.streaming).toBe(false); - expect(activeAssistantItemRef.current).toBeNull(); - }); - - it("supports out-of-order delta before started/completed", () => { - const activeAssistantItemRef = { current: null as string | null }; - const withDelta = applyEventToMessages( - [], - makeEvent({ - method: "item/agentMessage/delta", - createdAt: "2026-02-08T10:00:01.000Z", - itemId: "item-2", - textDelta: "Partial", - }), - activeAssistantItemRef, - ); - expect(withDelta[0]?.id).toBe("item-2"); - expect(withDelta[0]?.text).toBe("Partial"); - expect(withDelta[0]?.streaming).toBe(true); - expect(activeAssistantItemRef.current).toBe("item-2"); - - const completed = applyEventToMessages( - withDelta, - makeEvent({ - method: "item/completed", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-2", - type: "agentMessage", - text: "Partial + final", - }, - }, - }), - activeAssistantItemRef, - ); - - expect(completed[0]?.text).toBe("Partial + final"); - expect(completed[0]?.streaming).toBe(false); - }); - - it("clears all streaming flags when turn completes", () => { - const previous: ChatMessage[] = [ - { - id: "m-user", - role: "user", - text: "Hi", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - { - id: "m-assistant", - role: "assistant", - text: "Typing", - createdAt: "2026-02-08T10:00:01.000Z", - streaming: true, - }, - ]; - - const next = applyEventToMessages( - previous, - makeEvent({ - method: "turn/completed", - }), - { current: null }, - ); - - expect(next.every((entry) => entry.streaming === false)).toBe(true); - }); -}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b36a9648d9a31ddee2162c65a905fea4df3200e6..f8a5592be86ea070951243a3bf10fc69c4f4895b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,6 +1,6 @@ -import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; +import type { ProviderKind, ProviderSession } from "@t3tools/contracts"; + import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; -import { createWsNativeApi } from "./wsNativeApi"; export const PROVIDER_OPTIONS: Array<{ value: ProviderKind; @@ -11,39 +11,34 @@ export const PROVIDER_OPTIONS: Array<{ { value: "claudeCode", label: "Claude Code (soon)", available: false }, ]; -let cachedApi: NativeApi | undefined; - -export function readNativeApi(): NativeApi | undefined { - if (typeof window === "undefined") return undefined; - if (cachedApi) return cachedApi; - - // Prefer Electron preload bridge if available - if (window.nativeApi) { - cachedApi = window.nativeApi; - return cachedApi; - } - - // Fall back to WebSocket transport - cachedApi = createWsNativeApi(); - return cachedApi; -} - -export function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") return undefined; - return value as Record; -} - -function asArray(value: unknown): unknown[] | undefined { - return Array.isArray(value) ? value : undefined; +export interface WorkLogEntry { + id: string; + createdAt: string; + label: string; + detail?: string; + tone: "thinking" | "tool" | "info" | "error"; } -export function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; +export interface PendingApproval { + requestId: string; + requestKind: "command" | "file-change"; + createdAt: string; + detail?: string; } -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} +export type TimelineEntry = + | { + id: string; + kind: "message"; + createdAt: string; + message: ChatMessage; + } + | { + id: string; + kind: "work"; + createdAt: string; + entry: WorkLogEntry; + }; export function formatTimestamp(isoDate: string): string { return new Intl.DateTimeFormat(undefined, { @@ -58,7 +53,6 @@ export function formatDuration(durationMs: number): string { if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; - const minutes = Math.floor(durationMs / 60_000); const seconds = Math.round((durationMs % 60_000) / 1_000); if (seconds === 0) return `${minutes}m`; @@ -68,719 +62,72 @@ export function formatDuration(durationMs: number): string { export function formatElapsed(startIso: string, endIso: string | undefined): string | null { if (!endIso) return null; - const startedAt = Date.parse(startIso); const endedAt = Date.parse(endIso); if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { return null; } - return formatDuration(endedAt - startedAt); } -export interface WorkLogEntry { - id: string; - createdAt: string; - label: string; - detail?: string; - tone: "thinking" | "tool" | "info" | "error"; -} - -export interface PendingApproval { - requestId: string; - requestKind: "command" | "file-change"; - createdAt: string; - detail?: string; -} - -export type TimelineEntry = - | { - id: string; - kind: "message"; - createdAt: string; - message: ChatMessage; - } - | { - id: string; - kind: "work"; - createdAt: string; - entry: WorkLogEntry; - }; - -export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; - -function normalizeDetail(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function approvalDetail(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const command = asString(payload?.command); - if (command) return command; - return asString(payload?.reason); -} - -export function derivePendingApprovals(events: ProviderEvent[]): PendingApproval[] { - const pending = new Map(); - const ordered = [...events].toReversed(); - - for (const event of ordered) { - if ( - event.method === "session/closed" || - event.method === "session/exited" || - event.method === "turn/completed" - ) { - pending.clear(); - continue; - } - - const requestId = event.requestId ?? asString(asObject(event.payload)?.requestId); - if (!requestId) continue; - - if ( - event.kind === "request" && - (event.requestKind === "command" || event.requestKind === "file-change") - ) { - const detail = approvalDetail(event); - pending.set(requestId, { - requestId, - requestKind: event.requestKind, - createdAt: event.createdAt, - ...(detail ? { detail } : {}), - }); - continue; - } - - if (event.method === "item/requestApproval/decision") { - pending.delete(requestId); - } - } - - return Array.from(pending.values()); -} - -function normalizeItemType(raw: string | undefined): string { - if (!raw) return "item"; - return raw - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[._/-]/g, " ") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); -} - -function shouldDropItemType(type: string): boolean { - if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { - return true; - } - - return type === "work" || type.startsWith("work "); -} - -function shouldShowItemLifecycle(type: string): boolean { - return type.includes("tool") || type.includes("command") || type.includes("file change"); -} - -function shouldDropMethod(method: string): boolean { - return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); -} - -function itemTypeMeta(type: string): { - label: string; - tone: WorkLogEntry["tone"]; -} { - if (type.includes("command")) { - return { label: "Command run", tone: "tool" }; - } - if (type.includes("file change")) { - return { label: "File change", tone: "tool" }; - } - if (type.includes("tool")) { - return { label: "Tool call", tone: "tool" }; - } - return { label: "Work item", tone: "info" }; -} - -interface ItemLifecycleCandidate { - id: string; - itemId?: string; - createdAt: string; - label: string; - detail?: string; - tone: WorkLogEntry["tone"]; - phase: "started" | "completed"; -} - -function extractDetail( - payload: Record | undefined, - item: Record | undefined, -): string | undefined { - const candidates = [ - asString(item?.command), - asString(item?.tool), - asString(item?.name), - asString(item?.title), - asString(item?.summary), - asString(item?.text), - asString(item?.prompt), - asString(payload?.message), - asString(payload?.prompt), - asString(payload?.command), - ]; - - for (const candidate of candidates) { - const detail = normalizeDetail(candidate); - if (detail) return detail; - } - return undefined; -} - -function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null { - const payload = asObject(event.payload); - const item = asObject(payload?.item); - const normalizedType = normalizeItemType(asString(item?.type)); - if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { - return null; - } - if (!shouldShowItemLifecycle(normalizedType)) { - return null; - } - - const meta = itemTypeMeta(normalizedType); - const isStarted = event.method === "item/started"; - const isCompleted = event.method === "item/completed"; - if (!isStarted && !isCompleted) { - return null; - } - - const detail = extractDetail(payload, item); - const itemId = event.itemId ?? asString(item?.id); - return { - id: event.id, - ...(itemId ? { itemId } : {}), - createdAt: event.createdAt, - label: meta.label, - ...(detail ? { detail } : {}), - tone: meta.tone, - phase: isCompleted ? "completed" : "started", - }; -} - -function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "request") return null; - if (shouldDropMethod(event.method)) return null; - - if (event.method.includes("commandExecution")) { - return { - id: event.id, - createdAt: event.createdAt, - label: "Command approval requested", - tone: "tool", - }; - } - - if (event.method.includes("fileChange")) { - return { - id: event.id, - createdAt: event.createdAt, - label: "File-change approval requested", - tone: "tool", - }; - } - - if (event.method.includes("requestUserInput")) { - return { - id: event.id, - createdAt: event.createdAt, - label: "Tool requested user input", - tone: "tool", - }; - } - - return { - id: event.id, - createdAt: event.createdAt, - label: `Request: ${event.method}`, - tone: "info", - }; -} - -function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "notification") return null; - if (shouldDropMethod(event.method)) return null; - if (event.method === "item/agentMessage/delta") return null; - if (event.method === "turn/started") { - return null; - } - if (event.method === "thread/started") return null; - - if (event.method === "turn/completed") { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - const status = asString(turn?.status); - if (status !== "failed") { - return null; - } - const turnError = asObject(turn?.error); - const turnErrorMessage = asString(turnError?.message); - const turnErrorDetail = normalizeDetail(turnErrorMessage); - - return { - id: event.id, - createdAt: event.createdAt, - label: "Turn failed", - ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), - tone: "error", - }; - } - - if (event.method.startsWith("item/")) return null; - - return null; -} - -function entryFromError(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "error") return null; - if (shouldDropMethod(event.method)) return null; - const detail = normalizeDetail(event.message); - - return { - id: event.id, - createdAt: event.createdAt, - label: "Runtime error", - ...(detail ? { detail } : {}), - tone: "error", - }; -} - -function eventTurnId(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return event.turnId ?? asString(turn?.id); +export function derivePendingApprovals(_events: unknown[]): PendingApproval[] { + return []; } export function deriveWorkLogEntries( - events: ProviderEvent[], - turnId: string | undefined, + _events: unknown[], + _latestTurnId: string | undefined, ): WorkLogEntry[] { - const ordered = [...events].toReversed(); - const entries: WorkLogEntry[] = []; - const turnStartedAtIso = turnId - ? ordered.find((event) => { - if (event.method !== "turn/started") return false; - return eventTurnId(event) === turnId; - })?.createdAt - : undefined; - const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; - - const shouldIncludeEvent = (event: ProviderEvent): boolean => { - if (!turnId) return true; - const scopedTurnId = eventTurnId(event); - if (scopedTurnId && scopedTurnId !== turnId) { - return false; - } - - if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { - const eventAt = Date.parse(event.createdAt); - if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { - return false; - } - } - - return true; - }; - - const completedLifecycleItemIds = new Set(); - for (const event of ordered) { - if (!shouldIncludeEvent(event)) continue; - const candidate = lifecycleCandidateFromItemEvent(event); - if (candidate?.phase === "completed" && candidate.itemId) { - completedLifecycleItemIds.add(candidate.itemId); - } - } - - for (const event of ordered) { - if (!shouldIncludeEvent(event)) continue; - - const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); - if (lifecycleCandidate) { - if ( - lifecycleCandidate.phase === "started" && - lifecycleCandidate.itemId && - completedLifecycleItemIds.has(lifecycleCandidate.itemId) - ) { - continue; - } - if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) { - continue; - } - - entries.push({ - id: lifecycleCandidate.id, - createdAt: lifecycleCandidate.createdAt, - label: lifecycleCandidate.label, - ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}), - tone: lifecycleCandidate.tone, - }); - continue; - } - - const fromRequest = entryFromRequest(event); - if (fromRequest) { - entries.push(fromRequest); - continue; - } - - const fromNotification = entryFromNotification(event); - if (fromNotification) { - entries.push(fromNotification); - continue; - } - - const fromError = entryFromError(event); - if (fromError) { - entries.push(fromError); - } - } - - return entries; -} - -interface MutableTurnDiffSummary { - turnId: string; - completedAt: string | undefined; - status: string | undefined; - assistantMessageId: string | undefined; - hasTurnDiffUpdate: boolean; - filesByPath: Map; -} - -function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { - if (event.method !== "item/completed") { - return []; - } - - const item = asObject(asObject(event.payload)?.item); - const itemType = normalizeItemType(asString(item?.type)); - if (itemType !== "file change") { - return []; - } - - const changes = asArray(item?.changes) ?? []; - const parsed: TurnDiffFileChange[] = []; - for (const rawChange of changes) { - const change = asObject(rawChange); - if (!change) continue; - - const diff = normalizeDetail(asString(change.diff)); - const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); - if (!path) continue; - - const additions = asNumber(change.additions); - const deletions = asNumber(change.deletions); - const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; - const kind = normalizeDetail(asString(change.kind)); - parsed.push({ - path, - ...(kind ? { kind } : {}), - ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), - ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), - }); - } - - return parsed; -} - -function mergeTurnDiffFileChange( - byPath: Map, - incoming: TurnDiffFileChange, -): void { - const existing = byPath.get(incoming.path); - if (!existing) { - byPath.set(incoming.path, incoming); - return; - } - - if (existing.kind === undefined && incoming.kind !== undefined) { - existing.kind = incoming.kind; - } - if (existing.additions === undefined && incoming.additions !== undefined) { - existing.additions = incoming.additions; - } - if (existing.deletions === undefined && incoming.deletions !== undefined) { - existing.deletions = incoming.deletions; - } -} - -function parsePathFromDiff(diff: string): string | undefined { - const normalized = diff.replace(/\r\n/g, "\n"); - const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); - if (bPath && bPath[1]) return bPath[1]; - const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); - if (gitHeader && gitHeader[1]) return gitHeader[1]; - const direct = normalized.match(/^\+\+\+ (.+)$/m); - if (!direct || !direct[1] || direct[1] === "/dev/null") { - return undefined; - } - return direct[1]; -} - -export function splitUnifiedDiffByFile(diff: string): Map { - const normalized = diff.replace(/\r\n/g, "\n"); - const byPath = new Map(); - const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; - - if (headerMatches.length === 0) { - const path = parsePathFromDiff(normalized); - if (path) { - byPath.set(path, normalized.trim()); - } - return byPath; - } - - for (let index = 0; index < headerMatches.length; index += 1) { - const match = headerMatches[index]; - if (!match) continue; - const start = match.index ?? 0; - const nextStart = headerMatches[index + 1]?.index ?? normalized.length; - const segment = normalized.slice(start, nextStart).trim(); - const path = parsePathFromDiff(segment); - if (!path || segment.length === 0) continue; - byPath.set(path, segment); - } - - return byPath; -} - -export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { - const fileDiffsByPath = splitUnifiedDiffByFile(diff); - return Array.from(fileDiffsByPath.entries()) - .map(([path, fileDiff]) => { - const stat = countDiffStat(fileDiff); - return { - path, - additions: stat.additions, - deletions: stat.deletions, - }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); -} - -export function inferCheckpointTurnCountByTurnId( - summaries: TurnDiffSummary[], -): Record { - if (summaries.length === 0) { - return {}; - } - - const sorted = [...summaries].toSorted((a, b) => { - const aTime = Date.parse(a.completedAt); - const bTime = Date.parse(b.completedAt); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return a.completedAt.localeCompare(b.completedAt); - } - return aTime - bTime; - }); - - const next: Record = {}; - let fallbackTurnCount = 1; - for (const summary of sorted) { - if (typeof summary.checkpointTurnCount === "number") { - next[summary.turnId] = summary.checkpointTurnCount; - fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1); - continue; - } - next[summary.turnId] = fallbackTurnCount; - fallbackTurnCount += 1; - } - - return next; -} - -export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { - if (!patch) { - return { additions: 0, deletions: 0 }; - } - - let additions = 0; - let deletions = 0; - for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { - if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; - if (line.startsWith("+")) { - additions += 1; - continue; - } - if (line.startsWith("-")) { - deletions += 1; - } - } - - return { additions, deletions }; -} - -export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { - const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); - const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); - const ordered = - events.length < 2 || - Number.isNaN(firstEventAt) || - Number.isNaN(lastEventAt) || - firstEventAt >= lastEventAt - ? events - : [...events].toReversed(); - const byTurnId = new Map(); - - const ensureSummary = (turnId: string): MutableTurnDiffSummary => { - const existing = byTurnId.get(turnId); - if (existing) return existing; - const next: MutableTurnDiffSummary = { - turnId, - completedAt: undefined, - status: undefined, - assistantMessageId: undefined, - hasTurnDiffUpdate: false, - filesByPath: new Map(), - }; - byTurnId.set(turnId, next); - return next; - }; - - for (const event of ordered) { - const turnId = eventTurnId(event); - if (!turnId) continue; - const summary = ensureSummary(turnId); - - if (event.method === "turn/completed") { - if (summary.completedAt === undefined) { - summary.completedAt = event.createdAt; - } - const turn = asObject(asObject(event.payload)?.turn); - if (summary.status === undefined) { - summary.status = normalizeDetail(asString(turn?.status)); - } - } - - if (event.method === "item/completed") { - const item = asObject(asObject(event.payload)?.item); - if (asString(item?.type) === "agentMessage") { - const itemId = normalizeDetail(asString(item?.id)); - if (itemId && summary.assistantMessageId === undefined) { - summary.assistantMessageId = itemId; - } - } - } - - if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) { - summary.hasTurnDiffUpdate = true; - const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); - if (diff) { - for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { - mergeTurnDiffFileChange(summary.filesByPath, file); - } - } - } - - for (const file of parseFileChangeEntriesFromEvent(event)) { - mergeTurnDiffFileChange(summary.filesByPath, file); - } - } - - const summaries: TurnDiffSummary[] = []; - for (const summary of byTurnId.values()) { - if (!summary.completedAt) continue; - - summaries.push({ - turnId: summary.turnId, - completedAt: summary.completedAt, - ...(summary.status ? { status: summary.status } : {}), - files: Array.from(summary.filesByPath.values()).toSorted((a, b) => - a.path.localeCompare(b.path), - ), - ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), - }); - } - - summaries.sort((a, b) => { - const aTime = Date.parse(a.completedAt); - const bTime = Date.parse(b.completedAt); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.completedAt.localeCompare(a.completedAt); - } - return bTime - aTime; - }); - - return summaries; -} - -function toTimestamp(isoDate: string): number { - const parsed = Date.parse(isoDate); - return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; + return []; } export function deriveTimelineEntries( messages: ChatMessage[], workEntries: WorkLogEntry[], ): TimelineEntry[] { - const timeline: TimelineEntry[] = []; - let messageIndex = 0; - let workIndex = 0; - - while (messageIndex < messages.length || workIndex < workEntries.length) { - const message = messages[messageIndex]; - const workEntry = workEntries[workIndex]; - - if (!message && workEntry) { - timeline.push({ - id: `work:${workEntry.id}`, - kind: "work", - createdAt: workEntry.createdAt, - entry: workEntry, - }); - workIndex += 1; - continue; - } - - if (!workEntry && message) { - timeline.push({ - id: `message:${message.id}`, - kind: "message", - createdAt: message.createdAt, - message, - }); - messageIndex += 1; - continue; - } - - if (!message || !workEntry) { - break; - } - - const messageAt = toTimestamp(message.createdAt); - const workAt = toTimestamp(workEntry.createdAt); - - if (workAt <= messageAt) { - timeline.push({ - id: `work:${workEntry.id}`, - kind: "work", - createdAt: workEntry.createdAt, - entry: workEntry, - }); - workIndex += 1; - continue; - } - - timeline.push({ - id: `message:${message.id}`, - kind: "message", - createdAt: message.createdAt, - message, - }); - messageIndex += 1; + const messageRows: TimelineEntry[] = messages.map((message) => ({ + id: message.id, + kind: "message", + createdAt: message.createdAt, + message, + })); + const workRows: TimelineEntry[] = workEntries.map((entry) => ({ + id: entry.id, + kind: "work", + createdAt: entry.createdAt, + entry, + })); + return [...messageRows, ...workRows].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} + +export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { + const files: TurnDiffFileChange[] = []; + const lines = diff.split("\n"); + for (const line of lines) { + if (!line.startsWith("diff --git ")) continue; + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + const filePath = match?.[2] ?? match?.[1]; + if (!filePath) continue; + files.push({ path: filePath }); + } + return files; +} + +export function inferCheckpointTurnCountByTurnId( + summaries: TurnDiffSummary[], +): Record { + const sorted = [...summaries].sort((a, b) => a.completedAt.localeCompare(b.completedAt)); + const result: Record = {}; + for (let index = 0; index < sorted.length; index += 1) { + const summary = sorted[index]; + if (!summary) continue; + result[summary.turnId] = index + 1; } + return result; +} - return timeline; +export function deriveTurnDiffSummaries(_events: unknown[]): TurnDiffSummary[] { + return []; } export function derivePhase(session: ProviderSession | null): SessionPhase { @@ -789,162 +136,3 @@ export function derivePhase(session: ProviderSession | null): SessionPhase { if (session.status === "running") return "running"; return "ready"; } - -export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { - const payload = asObject(event.payload); - - if (event.method === "thread/started") { - const thread = asObject(payload?.thread); - return { - ...previous, - threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, - updatedAt: event.createdAt, - }; - } - - if (event.method === "turn/started") { - const turn = asObject(payload?.turn); - return { - ...previous, - status: "running", - activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, - updatedAt: event.createdAt, - }; - } - - if (event.method === "turn/completed") { - const turn = asObject(payload?.turn); - const status = asString(turn?.status); - const turnError = asObject(turn?.error); - return { - ...previous, - status: status === "failed" ? "error" : "ready", - activeTurnId: undefined, - lastError: asString(turnError?.message) ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - if (event.kind === "error") { - return { - ...previous, - status: "error", - lastError: event.message ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - if (event.method === "session/closed" || event.method === "session/exited") { - return { - ...previous, - status: "closed", - activeTurnId: undefined, - lastError: event.message ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - return { ...previous, updatedAt: event.createdAt }; -} - -export function applyEventToMessages( - previous: ChatMessage[], - event: ProviderEvent, - activeAssistantItemRef: { current: string | null }, -): ChatMessage[] { - const payload = asObject(event.payload); - - if (event.method === "item/started") { - const item = asObject(payload?.item); - if (asString(item?.type) !== "agentMessage") return previous; - const itemId = asString(item?.id); - if (!itemId) return previous; - - activeAssistantItemRef.current = itemId; - const seedText = asString(item?.text) ?? ""; - const filtered = previous.filter((entry) => entry.id !== itemId); - return [ - ...filtered, - { - id: itemId, - role: "assistant", - text: seedText, - createdAt: event.createdAt, - streaming: true, - }, - ]; - } - - if (event.method === "item/agentMessage/delta") { - const itemId = event.itemId ?? asString(payload?.itemId); - const delta = event.textDelta ?? asString(payload?.delta) ?? ""; - if (!itemId || !delta) return previous; - - const existingIndex = previous.findIndex((entry) => entry.id === itemId); - if (existingIndex === -1) { - activeAssistantItemRef.current = itemId; - return [ - ...previous, - { - id: itemId, - role: "assistant", - text: delta, - createdAt: event.createdAt, - streaming: true, - }, - ]; - } - - const updated = [...previous]; - const existing = updated[existingIndex]; - if (!existing) return previous; - updated[existingIndex] = { - ...existing, - text: `${existing.text}${delta}`, - streaming: true, - }; - return updated; - } - - if (event.method === "item/completed") { - const item = asObject(payload?.item); - if (asString(item?.type) !== "agentMessage") return previous; - const itemId = asString(item?.id); - if (!itemId) return previous; - - const fullText = asString(item?.text); - const existingIndex = previous.findIndex((entry) => entry.id === itemId); - if (existingIndex === -1) { - return [ - ...previous, - { - id: itemId, - role: "assistant", - text: fullText ?? "", - createdAt: event.createdAt, - streaming: false, - }, - ]; - } - - const updated = [...previous]; - const existing = updated[existingIndex]; - if (!existing) return previous; - updated[existingIndex] = { - ...existing, - text: fullText ?? existing.text, - streaming: false, - }; - - if (activeAssistantItemRef.current === itemId) { - activeAssistantItemRef.current = null; - } - return updated; - } - - if (event.method === "turn/completed") { - return previous.map((entry) => ({ ...entry, streaming: false })); - } - - return previous; -} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts deleted file mode 100644 index 6acd1e2859ec7cc23ea4849ae71cfe99fc167258..0000000000000000000000000000000000000000 --- a/apps/web/src/store.test.ts +++ /dev/null @@ -1,983 +0,0 @@ -import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; - -import { type AppState, reducer } from "./store"; -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, -} from "./types"; -import type { Thread } from "./types"; - -type TerminalStartedEvent = Extract; -type TerminalActivityEvent = Extract; - -function makeSession(overrides: Partial = {}): ProviderSession { - return { - sessionId: "sess-1", - provider: "codex", - status: "ready", - createdAt: "2026-02-09T00:00:00.000Z", - updatedAt: "2026-02-09T00:00:00.000Z", - ...overrides, - }; -} - -function makeEvent(overrides: Partial = {}): ProviderEvent { - return { - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: "2026-02-09T00:00:01.000Z", - method: "thread/started", - ...overrides, - }; -} - -function makeThread(overrides: Partial = {}): Thread { - return { - id: "thread-local-1", - codexThreadId: null, - projectId: "project-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: makeSession(), - messages: [], - events: [], - turnDiffSummaries: [], - error: null, - createdAt: "2026-02-09T00:00:00.000Z", - branch: null, - worktreePath: null, - ...overrides, - }; -} - -function makeState(thread: Thread): AppState { - return { - projects: [ - { - id: "project-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [thread], - threadsHydrated: true, - runtimeMode: "full-access", - }; -} - -function makeTerminalStartedEvent( - overrides: Partial = {}, -): TerminalStartedEvent { - return { - type: "started", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:01.000Z", - snapshot: { - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - cwd: "/tmp/project", - status: "running", - pid: 1234, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: "2026-02-09T00:00:01.000Z", - }, - ...overrides, - }; -} - -function makeTerminalActivityEvent( - overrides: Partial = {}, -): TerminalActivityEvent { - return { - type: "activity", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:02.000Z", - hasRunningSubprocess: true, - ...overrides, - }; -} - -describe("store reducer thread continuity", () => { - it("stores codexThreadId from UPDATE_SESSION", () => { - const state = makeState( - makeThread({ - session: null, - }), - ); - const next = reducer(state, { - type: "UPDATE_SESSION", - threadId: "thread-local-1", - session: makeSession({ threadId: "thr_123" }), - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_123"); - }); - - it("toggles terminal open state per thread", () => { - const state = makeState(makeThread({ terminalOpen: false })); - const next = reducer(state, { - type: "TOGGLE_THREAD_TERMINAL", - threadId: "thread-local-1", - }); - expect(next.threads[0]?.terminalOpen).toBe(true); - }); - - it("sets terminal open state per thread", () => { - const state = makeState(makeThread({ terminalOpen: true })); - const next = reducer(state, { - type: "SET_THREAD_TERMINAL_OPEN", - threadId: "thread-local-1", - open: false, - }); - expect(next.threads[0]?.terminalOpen).toBe(false); - }); - - it("sets terminal height per thread", () => { - const state = makeState(makeThread({ terminalHeight: 280 })); - const next = reducer(state, { - type: "SET_THREAD_TERMINAL_HEIGHT", - threadId: "thread-local-1", - height: 360, - }); - expect(next.threads[0]?.terminalHeight).toBe(360); - }); - - it("splits the active terminal into side-by-side mode", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "SPLIT_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - ]); - expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("creates a new full-width terminal and switches to tab mode", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "NEW_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ]); - expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); - }); - - it("switches the active terminal and restores its owning group", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ], - activeTerminalGroupId: "group-term-3", - }), - ); - const next = reducer(state, { - type: "SET_THREAD_ACTIVE_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("supports splitting beyond two terminals in the same group", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - const next = reducer(state, { - type: "SPLIT_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-3", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"]); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - }, - ]); - expect(next.threads[0]?.activeTerminalId).toBe("term-3"); - expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("caps split terminals at four per thread", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], - activeTerminalId: "term-4", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - const next = reducer(state, { - type: "SPLIT_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-5", - }); - - expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); - expect(next.threads[0]?.terminalIds).toEqual([ - DEFAULT_THREAD_TERMINAL_ID, - "term-2", - "term-3", - "term-4", - ]); - expect(next.threads[0]?.activeTerminalId).toBe("term-4"); - }); - - it("caps new terminals at four per thread", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], - activeTerminalId: "term-4", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - { id: "group-term-2", terminalIds: ["term-2"] }, - { id: "group-term-3", terminalIds: ["term-3"] }, - { id: "group-term-4", terminalIds: ["term-4"] }, - ], - activeTerminalGroupId: "group-term-4", - }), - ); - const next = reducer(state, { - type: "NEW_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-5", - }); - - expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); - expect(next.threads[0]?.terminalIds).toEqual([ - DEFAULT_THREAD_TERMINAL_ID, - "term-2", - "term-3", - "term-4", - ]); - expect(next.threads[0]?.activeTerminalId).toBe("term-4"); - expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4"); - }); - - it("closes a terminal and keeps grouped layout coherent", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - activeTerminalId: "term-2", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - const next = reducer(state, { - type: "CLOSE_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]); - expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ]); - }); - - it("closes the final terminal and hides the drawer", () => { - const state = makeState( - makeThread({ - terminalOpen: true, - runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }), - ); - const next = reducer(state, { - type: "CLOSE_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - }); - - expect(next.threads[0]?.terminalOpen).toBe(false); - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(next.threads[0]?.runningTerminalIds).toEqual([]); - expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ]); - }); - - it("tracks running terminals from subprocess activity events", () => { - const state = makeState(makeThread()); - const started = reducer(state, { - type: "APPLY_TERMINAL_EVENT", - event: makeTerminalStartedEvent(), - }); - expect(started.threads[0]?.runningTerminalIds).toEqual([]); - - const active = reducer(started, { - type: "APPLY_TERMINAL_EVENT", - event: makeTerminalActivityEvent(), - }); - expect(active.threads[0]?.runningTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - - const idle = reducer(active, { - type: "APPLY_TERMINAL_EVENT", - event: makeTerminalActivityEvent({ hasRunningSubprocess: false }), - }); - expect(idle.threads[0]?.runningTerminalIds).toEqual([]); - - const exited = reducer(active, { - type: "APPLY_TERMINAL_EVENT", - event: { - type: "exited", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:05.000Z", - exitCode: 0, - exitSignal: null, - }, - }); - expect(exited.threads[0]?.runningTerminalIds).toEqual([]); - }); - - it("keeps running status when another terminal in the thread is still running", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - - const next = reducer(state, { - type: "APPLY_TERMINAL_EVENT", - event: { - type: "exited", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:07.000Z", - exitCode: 0, - exitSignal: null, - }, - }); - - expect(next.threads[0]?.runningTerminalIds).toEqual(["term-2"]); - }); - - it("backfills codexThreadId from routed provider events", () => { - const state = makeState(makeThread({ codexThreadId: null })); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "thread/started", - payload: { thread: { id: "thr_backfilled" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); - }); - - it("ignores events from a foreign thread within the same session", () => { - const state = makeState(makeThread({ codexThreadId: "thr_expected" })); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/started", - threadId: "thr_unexpected", - payload: { turn: { id: "turn-1" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next).toBe(state); - }); - - it("rebases thread identity on thread/started during connect", () => { - const state = makeState( - makeThread({ - codexThreadId: "thr_old", - session: makeSession({ - status: "connecting", - threadId: "thr_old", - }), - }), - ); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "thread/started", - threadId: "thr_new", - payload: { thread: { id: "thr_new" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_new"); - expect(next.threads[0]?.session?.threadId).toBe("thr_new"); - }); - - it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => { - const state = makeState( - makeThread({ - events: [], - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-09T00:00:01.000Z", - files: [{ path: "src/existing.ts", kind: "modified" }], - }, - ], - }), - ); - - const withFileChange = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "item/completed", - turnId: "turn-2", - createdAt: "2026-02-09T00:00:02.000Z", - payload: { - item: { - type: "fileChange", - changes: [{ path: "src/new.ts", kind: "added" }], - }, - }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - "turn-1", - ]); - - const completed = reducer(withFileChange, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-2", - createdAt: "2026-02-09T00:00:03.000Z", - payload: { turn: { id: "turn-2", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - "turn-2", - "turn-1", - ]); - }); - - it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => { - const state = makeState(makeThread({ turnDiffSummaries: [] })); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:03.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1"); - expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1); - }); - - it("reconciles project ids by cwd when syncing backend projects", () => { - const state: AppState = { - projects: [ - { - id: "project-old-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - { - id: "project-old-b", - name: "B", - cwd: "/tmp/b", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - makeThread({ - id: "thread-a", - projectId: "project-old-a", - }), - makeThread({ - id: "thread-b", - projectId: "project-old-b", - }), - ], - threadsHydrated: true, - runtimeMode: "full-access", - }; - - const next = reducer(state, { - type: "SYNC_PROJECTS", - projects: [ - { - id: "project-new-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - }); - - expect(next.projects).toHaveLength(1); - expect(next.projects[0]?.id).toBe("project-new-a"); - // Preserve existing project UI preferences by cwd - expect(next.projects[0]?.expanded).toBe(false); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.id).toBe("thread-a"); - expect(next.threads[0]?.projectId).toBe("project-new-a"); - }); - - it("treats empty scripts from sync as authoritative", () => { - const state: AppState = { - projects: [ - { - id: "project-old-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: true, - scripts: [ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - }, - ], - threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], - threadsHydrated: true, - runtimeMode: "full-access", - }; - - const next = reducer(state, { - type: "SYNC_PROJECTS", - projects: [ - { - id: "project-new-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - ], - }); - - expect(next.projects[0]?.scripts).toEqual([]); - }); - - it("updates project scripts", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "SET_PROJECT_SCRIPTS", - projectId: "project-1", - scripts: [ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - }); - - expect(next.projects[0]?.scripts).toEqual([ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ]); - }); - - it("marks threads hydration state", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "SET_THREADS_HYDRATED", - hydrated: false, - }); - - expect(next.threadsHydrated).toBe(false); - }); - - it("deletes a project and all of its threads", () => { - const state: AppState = { - projects: [ - { - id: "project-1", - name: "One", - cwd: "/tmp/one", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - { - id: "project-2", - name: "Two", - cwd: "/tmp/two", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - makeThread({ - id: "thread-a", - projectId: "project-1", - }), - makeThread({ - id: "thread-b", - projectId: "project-2", - }), - ], - threadsHydrated: true, - runtimeMode: "full-access", - }; - - const next = reducer(state, { - type: "DELETE_PROJECT", - projectId: "project-1", - }); - - expect(next.projects).toHaveLength(1); - expect(next.projects[0]?.id).toBe("project-2"); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.id).toBe("thread-b"); - }); - - it("marks completion as seen immediately for the active thread", () => { - const state = makeState( - makeThread({ - session: makeSession({ - status: "running", - activeTurnId: "turn-1", - }), - lastVisitedAt: "2026-02-08T10:00:00.000Z", - }), - ); - - const completedAt = "2026-02-08T10:00:10.000Z"; - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: completedAt, - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - activeThreadId: "thread-local-1", - }); - - expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); - expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); - }); - - it("marks a thread as visited when selected", () => { - const state = makeState( - makeThread({ - latestTurnCompletedAt: "2026-02-08T10:00:10.000Z", - lastVisitedAt: "2026-02-08T10:00:00.000Z", - }), - ); - - const nextVisitedAt = "2026-02-08T10:00:20.000Z"; - const next = reducer(state, { - type: "MARK_THREAD_VISITED", - threadId: "thread-local-1", - visitedAt: nextVisitedAt, - }); - - expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt); - }); - - it("reverts thread state to a checkpoint snapshot", () => { - const state = makeState( - makeThread({ - codexThreadId: "thr_before", - session: makeSession({ - status: "running", - threadId: "thr_before", - activeTurnId: "turn-live", - }), - messages: [ - { - id: "m-1", - role: "user", - text: "First", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - { - id: "m-2", - role: "assistant", - text: "First reply", - createdAt: "2026-02-08T10:00:01.000Z", - streaming: false, - }, - { - id: "m-3", - role: "user", - text: "Second", - createdAt: "2026-02-08T10:00:02.000Z", - streaming: false, - }, - ], - events: [ - makeEvent({ - method: "turn/started", - turnId: "turn-live", - }), - ], - turnDiffSummaries: [ - { - turnId: "turn_1", - completedAt: "2026-02-08T10:00:01.000Z", - files: [{ path: "src/first.ts", kind: "modified" }], - checkpointTurnCount: 1, - }, - { - turnId: "turn_2", - completedAt: "2026-02-08T10:00:03.000Z", - files: [{ path: "src/second.ts", kind: "modified" }], - checkpointTurnCount: 2, - }, - ], - error: "temporary failure", - latestTurnId: "turn-live", - latestTurnStartedAt: "2026-02-08T10:00:03.000Z", - }), - ); - - const next = reducer(state, { - type: "REVERT_TO_CHECKPOINT", - threadId: "thread-local-1", - sessionId: "sess-1", - threadRuntimeId: "thr_after", - turnCount: 1, - messageCount: 2, - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_after"); - expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]); - expect(next.threads[0]?.events).toEqual([]); - expect(next.threads[0]?.error).toBeNull(); - expect(next.threads[0]?.session?.status).toBe("ready"); - expect(next.threads[0]?.session?.activeTurnId).toBeUndefined(); - expect(next.threads[0]?.session?.threadId).toBe("thr_after"); - expect(next.threads[0]?.latestTurnId).toBeUndefined(); - expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]); - }); - - it("keeps existing turn file metadata when later events for the same turn arrive", () => { - const state = makeState( - makeThread({ - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-09T00:00:03.000Z", - files: [ - { - path: "src/from-checkpoint.ts", - additions: 1, - deletions: 1, - }, - ], - }, - ], - }), - ); - - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:04.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ - "src/from-checkpoint.ts", - ]); - expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1); - expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1); - }); - - it("updates checkpoint turn counts from authoritative checkpoint mappings", () => { - const state = makeState( - makeThread({ - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-09T00:00:03.000Z", - files: [], - checkpointTurnCount: 1, - }, - { - turnId: "turn-2", - completedAt: "2026-02-09T00:00:04.000Z", - files: [], - checkpointTurnCount: 2, - }, - ], - }), - ); - - const next = reducer(state, { - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: "thread-local-1", - checkpointTurnCountByTurnId: { - "turn-1": 2, - "turn-2": 3, - }, - }); - - expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2); - expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3); - }); - - it("does not rederive completed turn summaries from late turn diff events", () => { - const completed = reducer(makeState(makeThread()), { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:03.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); - - const withLateDiff = reducer(completed, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:04.000Z", - payload: { - diff: [ - "diff --git a/src/a.ts b/src/a.ts", - "@@ -1 +1 @@", - "-old", - "+new", - "diff --git a/src/b.ts b/src/b.ts", - "@@ -1 +1 @@", - "-old", - "+new", - ].join("\n"), - }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); - }); -}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index cb45b5506cdcde65f101eb4f1ac0a5dcf331e3ae..1df450ac06a24187c753e7becde0b397b063146f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -8,19 +8,13 @@ import { useReducer, } from "react"; -import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; +import { + type OrchestrationReadModel, + normalizeProjectScripts, +} from "@t3tools/contracts"; import { resolveModelSlug } from "./model-logic"; -import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { - applyEventToMessages, - asObject, - asString, - deriveTurnDiffSummaries, - inferCheckpointTurnCountByTurnId, - evolveSession, -} from "./session-logic"; -import { - type ChatAttachment, + DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, MAX_THREAD_TERMINAL_COUNT, @@ -34,13 +28,10 @@ import { // ── Actions ────────────────────────────────────────────────────────── type Action = - | { type: "ADD_PROJECT"; project: Project } + | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } - | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | { type: "TOGGLE_PROJECT"; projectId: string } - | { type: "DELETE_PROJECT"; projectId: string } - | { type: "ADD_THREAD"; thread: Thread } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } @@ -48,46 +39,14 @@ type Action = | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } - | { - type: "APPLY_EVENT"; - event: ProviderEvent; - activeAssistantItemRef: { current: string | null }; - activeThreadId?: string | null; - } - | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } - | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } - | { - type: "PUSH_USER_MESSAGE"; - threadId: string; - id: string; - text: string; - attachments?: ChatAttachment[]; - } | { type: "SET_ERROR"; threadId: string; error: string | null } - | { type: "SET_THREAD_TITLE"; threadId: string; title: string } - | { type: "SET_THREAD_MODEL"; threadId: string; model: string } - | { - type: "REVERT_TO_CHECKPOINT"; - threadId: string; - sessionId: string; - threadRuntimeId: string; - turnCount: number; - messageCount: number; - } - | { - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS"; - threadId: string; - checkpointTurnCountByTurnId: Record; - } | { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null; } - | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } - | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } - | { type: "DELETE_THREAD"; threadId: string }; + | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; // ── State ──────────────────────────────────────────────────────────── @@ -116,6 +75,7 @@ const initialState: AppState = { threadsHydrated: false, runtimeMode: DEFAULT_RUNTIME_MODE, }; +const persistedExpandedProjectCwds = new Set(); // ── Helpers ────────────────────────────────────────────────────────── @@ -123,21 +83,24 @@ function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; try { - const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); - const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) => window.localStorage.getItem(key)); - const rawLegacy = legacyValues.find((value) => value !== null) ?? null; - const raw = rawCurrent ?? rawLegacy; + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; - const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1"); - const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); - if (!hydrated) return initialState; - - const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); - + const parsed = JSON.parse(raw) as { + runtimeMode?: RuntimeMode; + expandedProjectCwds?: string[]; + }; + persistedExpandedProjectCwds.clear(); + for (const cwd of parsed.expandedProjectCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedExpandedProjectCwds.add(cwd); + } + } return { - ...hydrated, - threads, - threadsHydrated: threads.length > 0, + ...initialState, + runtimeMode: + parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" + ? parsed.runtimeMode + : DEFAULT_RUNTIME_MODE, }; } catch { return initialState; @@ -148,7 +111,15 @@ function persistState(state: AppState): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + runtimeMode: state.runtimeMode, + expandedProjectCwds: state.projects + .filter((project) => project.expanded) + .map((project) => project.cwd), + }), + ); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } @@ -165,63 +136,6 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } -function mergeTurnDiffSummaries( - existing: Thread["turnDiffSummaries"], - next: Thread["turnDiffSummaries"], -): Thread["turnDiffSummaries"] { - if (next.length === 0) return existing; - - const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); - const merged = next.map((summary) => { - const previous = existingByTurnId.get(summary.turnId); - if (!previous) { - return summary; - } - - const files = - summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files; - - return { - ...summary, - files, - ...(summary.assistantMessageId - ? {} - : previous.assistantMessageId - ? { assistantMessageId: previous.assistantMessageId } - : {}), - ...(typeof summary.checkpointTurnCount === "number" - ? {} - : typeof previous.checkpointTurnCount === "number" - ? { checkpointTurnCount: previous.checkpointTurnCount } - : {}), - }; - }); - - const mergedTurnIds = new Set(merged.map((summary) => summary.turnId)); - for (const summary of existing) { - if (!mergedTurnIds.has(summary.turnId)) { - merged.push(summary); - } - } - - const sorted = merged.toSorted((a, b) => { - const aTime = Date.parse(a.completedAt); - const bTime = Date.parse(b.completedAt); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.completedAt.localeCompare(a.completedAt); - } - return bTime - aTime; - }); - - const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted); - return sorted.map((summary) => - typeof summary.checkpointTurnCount === "number" - ? summary - : Object.assign({}, summary, { - checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId], - }), - ); -} function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); @@ -409,108 +323,80 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { }); } -function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { - return threads.find((t) => t.session?.sessionId === sessionId); -} - -function getEventTurnId(event: ProviderEvent): string | undefined { - if (event.turnId) return event.turnId; - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return asString(turn?.id); -} - -function getEventThreadId(event: ProviderEvent): string | undefined { - if (event.threadId) return event.threadId; - const payload = asObject(event.payload); - const payloadThread = asObject(payload?.thread); - const payloadMessage = asObject(payload?.msg); - return ( - asString(payload?.threadId) ?? - asString(payloadThread?.id) ?? - asString(payload?.conversationId) ?? - asString(payload?.thread_id) ?? - asString(payloadMessage?.thread_id) - ); -} - -function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { - const eventThreadId = getEventThreadId(event); - if (!eventThreadId) { - return false; - } - - const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; - if (!expectedThreadId || eventThreadId === expectedThreadId) { - return false; - } - - // During connect, accept a thread/started notification as an identity rebind. - if (event.method === "thread/started" && thread.session?.status === "connecting") { - return false; - } - - return true; -} - -function durationMs(startIso: string, endIso: string): number | undefined { - const start = Date.parse(startIso); - const end = Date.parse(endIso); - if (Number.isNaN(start) || Number.isNaN(end) || end < start) { - return undefined; - } - - return end - start; -} - -function updateTurnFields(thread: Thread, event: ProviderEvent): Partial { - if (event.method === "turn/started") { - return { - latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, - latestTurnStartedAt: event.createdAt, - latestTurnCompletedAt: undefined, - latestTurnDurationMs: undefined, - }; - } - - if (event.method === "turn/completed") { - const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; - const startedAt = - completedTurnId && completedTurnId === thread.latestTurnId - ? thread.latestTurnStartedAt - : undefined; - const elapsed = - startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; - - return { - latestTurnId: completedTurnId ?? thread.latestTurnId, - latestTurnCompletedAt: event.createdAt, - latestTurnDurationMs: elapsed, - }; - } - - return {}; -} // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { switch (action.type) { - case "ADD_PROJECT": - if (state.projects.some((project) => project.cwd === action.project.cwd)) { - return state; - } + case "SYNC_SERVER_READ_MODEL": { + const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); + const projects = action.readModel.projects.map((project) => { + const existing = state.projects.find((entry) => entry.id === project.id); + return { + id: project.id, + name: project.name, + cwd: project.cwd, + model: resolveModelSlug(project.model), + expanded: + existing?.expanded ?? + (persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.cwd) + : true), + scripts: existing?.scripts ?? [], + }; + }); + const threads = action.readModel.threads.map((thread) => { + const existing = existingThreadById.get(thread.id); + return normalizeThreadTerminals({ + id: thread.id, + codexThreadId: thread.session?.threadId ?? null, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug(thread.model), + terminalOpen: existing?.terminalOpen ?? false, + terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: existing?.runningTerminalIds ?? [], + activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: existing?.terminalGroups ?? [ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ], + activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, + session: thread.session + ? { + sessionId: thread.session.sessionId, + provider: thread.session.provider, + status: thread.session.status, + threadId: thread.session.threadId, + activeTurnId: thread.session.activeTurnId ?? undefined, + createdAt: thread.session.createdAt, + updatedAt: thread.session.updatedAt, + ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + } + : null, + messages: thread.messages.map((message) => ({ ...message })), + error: thread.error, + createdAt: thread.createdAt, + latestTurnId: thread.latestTurnId ?? undefined, + latestTurnStartedAt: thread.latestTurnStartedAt ?? undefined, + latestTurnCompletedAt: thread.latestTurnCompletedAt ?? undefined, + latestTurnDurationMs: thread.latestTurnDurationMs ?? undefined, + lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ + ...summary, + files: summary.files.map((file) => ({ ...file })), + })), + }); + }); return { ...state, - projects: [ - ...state.projects, - { - ...action.project, - model: resolveModelSlug(action.project.model), - scripts: normalizeProjectScripts(action.project.scripts), - }, - ], + projects, + threads, + threadsHydrated: true, }; + } case "SET_PROJECT_SCRIPTS": return { @@ -531,46 +417,6 @@ export function reducer(state: AppState, action: Action): AppState { threadsHydrated: action.hydrated, }; - case "SYNC_PROJECTS": { - const previousByCwd = new Map( - state.projects.map((project) => [project.cwd, project] as const), - ); - const nextProjects = action.projects.map((project) => { - const previous = previousByCwd.get(project.cwd); - const scripts = normalizeProjectScripts(project.scripts); - return { - ...project, - model: resolveModelSlug(previous?.model ?? project.model), - expanded: previous?.expanded ?? project.expanded, - scripts, - }; - }); - const previousProjectById = new Map( - state.projects.map((project) => [project.id, project] as const), - ); - const nextProjectIdByCwd = new Map( - nextProjects.map((project) => [project.cwd, project.id] as const), - ); - const nextThreads = state.threads - .map((thread) => { - const previousProject = previousProjectById.get(thread.projectId); - if (!previousProject) return null; - const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); - if (!mappedProjectId) return null; - return normalizeThreadTerminals({ - ...thread, - projectId: mappedProjectId, - }); - }) - .filter((thread): thread is Thread => thread !== null); - - return { - ...state, - projects: nextProjects, - threads: nextThreads, - }; - } - case "TOGGLE_PROJECT": return { ...state, @@ -579,34 +425,6 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "DELETE_PROJECT": { - const projects = state.projects.filter((project) => project.id !== action.projectId); - if (projects.length === state.projects.length) { - return state; - } - - const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); - - return { - ...state, - projects, - threads, - }; - } - - case "ADD_THREAD": { - const nextThread = normalizeThreadTerminals({ - ...action.thread, - model: resolveModelSlug(action.thread.model), - lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, - turnDiffSummaries: action.thread.turnDiffSummaries ?? [], - }); - return { - ...state, - threads: [...state.threads, nextThread], - }; - } - case "TOGGLE_THREAD_TERMINAL": return { ...state, @@ -774,118 +592,6 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "APPLY_TERMINAL_EVENT": - if (!state.threads.some((thread) => thread.id === action.event.threadId)) { - return state; - } - return { - ...state, - threads: updateThread(state.threads, action.event.threadId, (thread) => { - const normalizedThread = normalizeThreadTerminals(thread); - const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds); - if (action.event.type === "started" || action.event.type === "restarted") { - runningTerminalIdSet.delete(action.event.terminalId); - } else if (action.event.type === "activity") { - if (action.event.hasRunningSubprocess) { - runningTerminalIdSet.add(action.event.terminalId); - } else { - runningTerminalIdSet.delete(action.event.terminalId); - } - } else if (action.event.type === "exited" || action.event.type === "error") { - runningTerminalIdSet.delete(action.event.terminalId); - } - - return normalizeThreadTerminals({ - ...normalizedThread, - runningTerminalIds: [...runningTerminalIdSet], - }); - }), - }; - - case "APPLY_EVENT": { - const { event, activeAssistantItemRef, activeThreadId } = action; - const target = findThreadBySessionId(state.threads, event.sessionId); - if (!target) return state; - if (shouldIgnoreForeignThreadEvent(target, event)) return state; - - return { - ...state, - threads: updateThread(state.threads, target.id, (t) => { - const nextEvents = [event, ...t.events]; - const eventTurnId = getEventTurnId(event); - const hasCompletedSummaryForTurn = Boolean( - eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId), - ); - const itemType = asString(asObject(asObject(event.payload)?.item)?.type); - const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); - const isMetadataItemCompleted = - event.method === "item/completed" && - (normalizedItemType === "agentmessage" || normalizedItemType === "filechange"); - const shouldRederiveDiffs = - event.method === "turn/completed" || - (hasCompletedSummaryForTurn && isMetadataItemCompleted); - const turnDiffSummaries = shouldRederiveDiffs - ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) - : t.turnDiffSummaries; - const eventThreadId = getEventThreadId(event); - const shouldRebindIdentity = - event.method === "thread/started" && t.session?.status === "connecting"; - return { - ...t, - codexThreadId: shouldRebindIdentity - ? (eventThreadId ?? t.codexThreadId) - : (t.codexThreadId ?? eventThreadId ?? null), - error: event.kind === "error" && event.message ? event.message : t.error, - session: t.session ? evolveSession(t.session, event) : t.session, - messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), - events: nextEvents, - turnDiffSummaries, - ...updateTurnFields(t, event), - ...(event.method === "turn/completed" && t.id === activeThreadId - ? { lastVisitedAt: event.createdAt } - : {}), - }; - }), - }; - } - - case "UPDATE_SESSION": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - session: action.session, - codexThreadId: action.session.threadId ?? t.codexThreadId, - events: [], - error: null, - latestTurnId: undefined, - latestTurnStartedAt: undefined, - latestTurnCompletedAt: undefined, - latestTurnDurationMs: undefined, - })), - }; - - case "PUSH_USER_MESSAGE": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - messages: [ - ...t.messages, - { - id: action.id, - role: "user" as const, - text: action.text, - ...(action.attachments && action.attachments.length > 0 - ? { attachments: action.attachments } - : {}), - createdAt: new Date().toISOString(), - streaming: false, - }, - ], - })), - }; - case "SET_ERROR": return { ...state, @@ -895,90 +601,6 @@ export function reducer(state: AppState, action: Action): AppState { })), }; - case "SET_THREAD_TITLE": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - title: action.title, - })), - }; - - case "SET_THREAD_MODEL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - model: resolveModelSlug(action.model), - })), - }; - - case "REVERT_TO_CHECKPOINT": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => { - const nextMessageCount = Math.max(0, Math.floor(action.messageCount)); - const nextTurnCount = Math.max(0, Math.floor(action.turnCount)); - const now = new Date().toISOString(); - return { - ...t, - codexThreadId: action.threadRuntimeId, - session: - t.session?.sessionId === action.sessionId - ? { - ...t.session, - status: "ready", - threadId: action.threadRuntimeId, - activeTurnId: undefined, - updatedAt: now, - lastError: undefined, - } - : t.session, - messages: t.messages.slice(0, nextMessageCount), - events: [], - turnDiffSummaries: t.turnDiffSummaries.filter( - (summary) => - typeof summary.checkpointTurnCount === "number" && - summary.checkpointTurnCount <= nextTurnCount, - ), - error: null, - latestTurnId: undefined, - latestTurnStartedAt: undefined, - latestTurnCompletedAt: undefined, - latestTurnDurationMs: undefined, - lastVisitedAt: now, - }; - }), - }; - - case "SET_THREAD_TURN_CHECKPOINT_COUNTS": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => { - const hasUpdates = t.turnDiffSummaries.some( - (summary) => - action.checkpointTurnCountByTurnId[summary.turnId] !== undefined && - action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount, - ); - if (!hasUpdates) { - return t; - } - return { - ...t, - turnDiffSummaries: t.turnDiffSummaries.map((summary) => { - const turnCount = action.checkpointTurnCountByTurnId[summary.turnId]; - if (turnCount === undefined) { - return summary; - } - return { - ...summary, - checkpointTurnCount: turnCount, - }; - }), - }; - }), - }; - case "SET_THREAD_BRANCH": { return { ...state, @@ -997,29 +619,12 @@ export function reducer(state: AppState, action: Action): AppState { }; } - case "MARK_THREAD_VISITED": { - const visitedAt = action.visitedAt ?? new Date().toISOString(); - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - lastVisitedAt: visitedAt, - })), - }; - } - case "SET_RUNTIME_MODE": return { ...state, runtimeMode: action.mode, }; - case "DELETE_THREAD": - return { - ...state, - threads: state.threads.filter((t) => t.id !== action.threadId), - }; - default: return state; } diff --git a/apps/web/src/threadFactory.ts b/apps/web/src/threadFactory.ts index 8b84491640fa15c149eb7540e83b9a95c72cc7f2..01a638140b98c500bf0934adbf553f2f27240a24 100644 --- a/apps/web/src/threadFactory.ts +++ b/apps/web/src/threadFactory.ts @@ -32,7 +32,6 @@ export function createThread(projectId: string, options: CreateThreadOptions = { activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], - events: [], turnDiffSummaries: [], error: null, createdAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index e2902680bf354cb94c7940bb40000faf83cb2afd..88188e426da5f24f2bcd09b8ef641e28ca3cc31a 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,6 +1,5 @@ import type { ProjectScript as ContractProjectScript, - ProviderEvent, ProviderSession, } from "@t3tools/contracts"; @@ -77,7 +76,6 @@ export interface Thread { activeTerminalGroupId: string; session: ProviderSession | null; messages: ChatMessage[]; - events: ProviderEvent[]; error: string | null; createdAt: string; latestTurnId?: string | undefined; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index d56270697e34b061302d113d1a2933c0e0c12470..423b86d3fc7d1ba79f50e04ab538f668bfe10fc2 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -24,7 +24,6 @@ function makeThread(overrides: Partial = {}): Thread { activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], - events: [], turnDiffSummaries: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 9eacc9ed1f29f0692452fa25f15c69f55ddf9af9..7a52f75365bb7d0c99736212d48f1b0407b4bf9e 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,4 +1,13 @@ -import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; +import { + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, + type NativeApi, + WS_CHANNELS, + WS_METHODS, + type OrchestrationEvent, + type OrchestrationReadModel, + type WsWelcomePayload, +} from "@t3tools/contracts"; import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; @@ -90,13 +99,10 @@ export function createWsNativeApi(): NativeApi { interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), - listSessions: () => transport.request(WS_METHODS.providersListSessions), listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), revertToCheckpoint: (input) => transport.request(WS_METHODS.providersRevertToCheckpoint, input), - onEvent: (callback) => - transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), }, projects: { list: () => transport.request(WS_METHODS.projectsList), @@ -149,6 +155,26 @@ export function createWsNativeApi(): NativeApi { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, + orchestration: { + getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), + dispatchCommand: (command) => + transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), + replayEvents: (fromSequenceExclusive) => + transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), + onReadModel: (callback) => + transport.subscribe( + ORCHESTRATION_WS_CHANNELS.readModel, + (data: unknown) => { + const payload = data as { snapshot: OrchestrationReadModel }; + callback(payload.snapshot); + }, + ), + onDomainEvent: (callback) => + transport.subscribe( + ORCHESTRATION_WS_CHANNELS.domainEvent, + callback as (data: unknown) => void, + ), + }, }; instance = { api, transport }; diff --git a/bun.lock b/bun.lock index 1798b17ea0a7a8be5b2be7f4fda51a690ac1c1da..ae9ab554c3c5013a4aee88b909b4d5a17bf7f401 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,8 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "better-sqlite3": "^12.6.2", + "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", @@ -41,6 +43,7 @@ "devDependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", @@ -87,6 +90,7 @@ "name": "@t3tools/contracts", "version": "0.0.0", "dependencies": { + "effect": "^3.19.19", "zod": "^3.24.1", }, "devDependencies": { @@ -494,6 +498,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + "@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=="], "@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 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "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=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -624,6 +640,8 @@ "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=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -656,6 +674,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -686,6 +706,8 @@ "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=="], + "effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + "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=="], "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], @@ -726,22 +748,30 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "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=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "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=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "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=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -760,6 +790,8 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "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 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1006,6 +1044,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "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 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], @@ -1060,6 +1104,8 @@ "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=="], + "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=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1070,10 +1116,14 @@ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "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=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "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 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "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=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "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 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1150,6 +1204,10 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "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=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1168,10 +1226,14 @@ "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=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "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=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -1192,6 +1254,10 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "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=="], + + "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=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -1226,6 +1292,8 @@ "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=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "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=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], @@ -1276,6 +1344,8 @@ "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=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "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 @@ "@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=="], + "@types/better-sqlite3/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "@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=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1422,6 +1494,8 @@ "@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=="], + "@types/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], diff --git a/packages/contracts/package.json b/packages/contracts/package.json index bed19b011ad96ae315398d0796f81f73b7e56231..87ccb84cef60a9cca28065908676774a5cd76777 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -23,6 +23,7 @@ "test": "vitest run" }, "dependencies": { + "effect": "^3.19.19", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5e705e418f4a6cb350162409f0308cc90d62ce4f..fea1328075006b7e47884fae17cfccf0ae049a8f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,3 +9,4 @@ export * from "./keybindings"; export * from "./server"; export * from "./project"; export * from "./git"; +export * from "./orchestration"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea5bc6ec4de8fce52354d76e51bbe65030e67fe3..b3649d0907f84806f47afd05b19a7b8efcca1cf7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -16,7 +16,6 @@ import type { GitStatusResult, } from "./git"; import type { - ProviderEvent, ProviderGetCheckpointDiffInput, ProviderGetCheckpointDiffResult, ProviderInterruptTurnInput, @@ -53,6 +52,11 @@ import type { } from "./terminal"; import type { NewTodoInput, Todo } from "./todo"; import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import type { + OrchestrationCommand, + OrchestrationEvent, + OrchestrationReadModel, +} from "./orchestration"; export const EDITORS = [ { id: "cursor", label: "Cursor", command: "cursor" }, @@ -94,7 +98,6 @@ export interface NativeApi { interruptTurn: (input: ProviderInterruptTurnInput) => Promise; respondToRequest: (input: ProviderRespondToRequestInput) => Promise; stopSession: (input: ProviderStopSessionInput) => Promise; - listSessions: () => Promise; listCheckpoints: ( input: ProviderListCheckpointsInput, ) => Promise; @@ -104,7 +107,6 @@ export interface NativeApi { revertToCheckpoint: ( input: ProviderRevertToCheckpointInput, ) => Promise; - onEvent: (callback: (event: ProviderEvent) => void) => () => void; }; projects: { list: () => Promise; @@ -140,4 +142,11 @@ export interface NativeApi { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; + orchestration: { + getSnapshot: () => Promise; + dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>; + replayEvents: (fromSequenceExclusive: number) => Promise; + onReadModel: (callback: (snapshot: OrchestrationReadModel) => void) => () => void; + onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; + }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e1ee674cca40ffdaa12c48d111ded1071b3b28e --- /dev/null +++ b/packages/contracts/src/orchestration.ts @@ -0,0 +1,223 @@ +import { Schema } from "effect"; + +export const ORCHESTRATION_WS_METHODS = { + getSnapshot: "orchestration.getSnapshot", + dispatchCommand: "orchestration.dispatchCommand", + replayEvents: "orchestration.replayEvents", +} as const; + +export const ORCHESTRATION_WS_CHANNELS = { + readModel: "orchestration.readModel", + domainEvent: "orchestration.domainEvent", +} as const; + +const IsoDateTimeSchema = Schema.String; +const IdSchema = Schema.String; + +export const OrchestrationMessageSchema = Schema.Struct({ + id: IdSchema, + role: Schema.Literal("user", "assistant"), + text: Schema.String, + createdAt: IsoDateTimeSchema, + streaming: Schema.Boolean, +}); + +export type OrchestrationMessage = Schema.Schema.Type; + +export const OrchestrationSessionSchema = Schema.Struct({ + sessionId: IdSchema, + status: Schema.Literal("connecting", "ready", "running", "error", "closed"), + provider: Schema.Literal("codex", "claudeCode"), + threadId: IdSchema, + activeTurnId: Schema.NullOr(Schema.String), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, + lastError: Schema.NullOr(Schema.String), +}); + +export type OrchestrationSession = Schema.Schema.Type; + +export const OrchestrationTurnDiffFileSchema = Schema.Struct({ + path: Schema.String, + kind: Schema.optional(Schema.String), + additions: Schema.optional(Schema.Number), + deletions: Schema.optional(Schema.Number), +}); + +export type OrchestrationTurnDiffFile = Schema.Schema.Type; + +export const OrchestrationTurnDiffSummarySchema = Schema.Struct({ + turnId: IdSchema, + completedAt: IsoDateTimeSchema, + status: Schema.optional(Schema.String), + files: Schema.Array(OrchestrationTurnDiffFileSchema), + assistantMessageId: Schema.optional(Schema.String), + checkpointTurnCount: Schema.optional(Schema.Number), +}); + +export type OrchestrationTurnDiffSummary = Schema.Schema.Type< + typeof OrchestrationTurnDiffSummarySchema +>; + +export const OrchestrationThreadSchema = Schema.Struct({ + id: IdSchema, + projectId: IdSchema, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, + latestTurnId: Schema.NullOr(Schema.String), + latestTurnStartedAt: Schema.NullOr(Schema.String), + latestTurnCompletedAt: Schema.NullOr(Schema.String), + latestTurnDurationMs: Schema.NullOr(Schema.Number), + messages: Schema.Array(OrchestrationMessageSchema), + session: Schema.NullOr(OrchestrationSessionSchema), + turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema), + error: Schema.NullOr(Schema.String), +}); + +export type OrchestrationThread = Schema.Schema.Type; + +export const OrchestrationProjectSchema = Schema.Struct({ + id: IdSchema, + name: Schema.String, + cwd: Schema.String, + model: Schema.String, + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, +}); + +export type OrchestrationProject = Schema.Schema.Type; + +export const OrchestrationGitReadModelSchema = Schema.Struct({ + projectId: IdSchema, + branch: Schema.NullOr(Schema.String), + hasWorkingTreeChanges: Schema.Boolean, + aheadCount: Schema.Number, + behindCount: Schema.Number, + updatedAt: IsoDateTimeSchema, +}); + +export type OrchestrationGitReadModel = Schema.Schema.Type; + +export const OrchestrationReadModelSchema = Schema.Struct({ + sequence: Schema.Number, + projects: Schema.Array(OrchestrationProjectSchema), + threads: Schema.Array(OrchestrationThreadSchema), + gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }), + updatedAt: IsoDateTimeSchema, +}); + +export type OrchestrationReadModel = Schema.Schema.Type; + +export const CreateProjectCommandSchema = Schema.Struct({ + type: Schema.Literal("project.create"), + commandId: IdSchema, + projectId: IdSchema, + name: Schema.String, + cwd: Schema.String, + model: Schema.String, + createdAt: IsoDateTimeSchema, +}); + +export const DeleteProjectCommandSchema = Schema.Struct({ + type: Schema.Literal("project.delete"), + commandId: IdSchema, + projectId: IdSchema, + createdAt: IsoDateTimeSchema, +}); + +export const CreateThreadCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.create"), + commandId: IdSchema, + threadId: IdSchema, + projectId: IdSchema, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + createdAt: IsoDateTimeSchema, +}); + +export const DeleteThreadCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.delete"), + commandId: IdSchema, + threadId: IdSchema, + createdAt: IsoDateTimeSchema, +}); + +export const UpdateThreadMetaCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.meta.update"), + commandId: IdSchema, + threadId: IdSchema, + title: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + branch: Schema.optional(Schema.NullOr(Schema.String)), + worktreePath: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: IsoDateTimeSchema, +}); + +export const SendMessageCommandSchema = Schema.Struct({ + type: Schema.Literal("message.send"), + commandId: IdSchema, + threadId: IdSchema, + messageId: IdSchema, + role: Schema.Literal("user", "assistant"), + text: Schema.String, + streaming: Schema.optional(Schema.Boolean), + createdAt: IsoDateTimeSchema, +}); + +export const SetThreadSessionSchema = Schema.Struct({ + type: Schema.Literal("thread.session"), + commandId: IdSchema, + threadId: IdSchema, + session: OrchestrationSessionSchema, + createdAt: IsoDateTimeSchema, +}); + +export const UpsertGitReadModelCommandSchema = Schema.Struct({ + type: Schema.Literal("git.readModel.upsert"), + commandId: IdSchema, + projectId: IdSchema, + branch: Schema.NullOr(Schema.String), + hasWorkingTreeChanges: Schema.Boolean, + aheadCount: Schema.Number, + behindCount: Schema.Number, + createdAt: IsoDateTimeSchema, +}); + +export const OrchestrationCommandSchema = Schema.Union( + CreateProjectCommandSchema, + DeleteProjectCommandSchema, + CreateThreadCommandSchema, + DeleteThreadCommandSchema, + UpdateThreadMetaCommandSchema, + SendMessageCommandSchema, + SetThreadSessionSchema, + UpsertGitReadModelCommandSchema, +); + +export type OrchestrationCommand = Schema.Schema.Type; + +export const OrchestrationEventSchema = Schema.Struct({ + sequence: Schema.Number, + eventId: IdSchema, + type: Schema.String, + aggregateType: Schema.String, + aggregateId: Schema.String, + occurredAt: IsoDateTimeSchema, + commandId: Schema.NullOr(Schema.String), + payload: Schema.Unknown, +}); + +export type OrchestrationEvent = Schema.Schema.Type; + +export const OrchestrationReadModelPushSchema = Schema.Struct({ + sequence: Schema.Number, + snapshot: OrchestrationReadModelSchema, +}); + +export type OrchestrationReadModelPush = Schema.Schema.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 61f583354bd2aad5cea29b7ee93258411409e6fa..22804a98cc59c7367c55960b2e27525b018354a8 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -9,7 +9,6 @@ export const WS_METHODS = { providersInterruptTurn: "providers.interruptTurn", providersRespondToRequest: "providers.respondToRequest", providersStopSession: "providers.stopSession", - providersListSessions: "providers.listSessions", providersListCheckpoints: "providers.listCheckpoints", providersGetCheckpointDiff: "providers.getCheckpointDiff", providersRevertToCheckpoint: "providers.revertToCheckpoint", @@ -51,7 +50,6 @@ export const WS_METHODS = { // ── Push Event Channels ────────────────────────────────────────────── export const WS_CHANNELS = { - providerEvent: "providers.event", terminalEvent: "terminal.event", serverWelcome: "server.welcome", } as const;